前端测试一直是个让人又爱又恨的话题。单元测试有 Jest、Vitest 这类工具撑场面,但走到端到端(E2E)测试这一步,很多团队要么靠人工点点点,要么在 Selenium 生态里维护一堆脆弱得要命的脚本。明明每一步都写了等待、定位、重试,跑起来还是动不动就超时,CI 里一挂挂一片,谁都不敢碰。
Cypress 的出现,很大程度上就是为了解决这种局面。它不是一个简单的 Selenium 替代品,而是从头把架构、执行方式、调试体验重新想了一遍的测试框架。如果你所在团队正准备引入 E2E 测试,或者你已经在用其他工具但维护成本越来越高,这篇文章想帮你把 Cypress 的核心原理、使用姿势、常见坑和工程实践一次聊清楚。
本文不会只停留在跑通一个 demo,而是会涉及到:Cypress 和传统 E2E 工具的架构差异在哪里、它的自动等待机制为什么省心又容易让人误解、实际项目里怎么组织测试代码、怎么在 CI 里跑起来,以及那些会让新手恨不得删库跑路的典型问题。
1. 这篇文章真正要解决的问题
很多人第一次接触 Cypress 时,会直接把它归类为“又一个 E2E 工具”。这个判断不算错,但会低估它带来的变化。Cypress 真正改变的不是测试的写法,而是测试的执行模型和开发体验。
传统 E2E 测试工具大多基于 Selenium WebDriver,走的是“客户端 - 服务端”模式:测试代码通过 HTTP 协议驱动浏览器,浏览器里跑的是被测应用,测试逻辑跑在另一个进程里。这种架构带来的问题很直接:两个进程之间通信有延迟,测试代码很难知道页面到底什么时候准备好了,于是只能靠 sleep 或者显式等待硬扛。网络一抖、接口一慢,测试就挂了。挂的原因还不是功能挂了,而是“等的时间不够”。
Cypress 换了另一条路线。它直接运行在浏览器里,测试代码和被测应用共享同一个运行环境。这不是优化,而是架构层面的重构。带来的直接好处有三个:
- 不再需要写一堆“等待 3 秒”的代码。Cypress 会自动重试断言和元素查找,直到超时。
- 调试体验从“看日志猜状态”变成了随时查看每个步骤的页面快照。
- 运行速度快,因为不需要在浏览器外部起一个服务去反复通信。
但这不意味着 Cypress 是万能的。它对多标签页支持不友好,对 iframe 的支持也一直有各种限制。换句话说,Cypress 适合大多数 Web 应用,但不是所有场景的银弹。
这篇文章适合以下读者:
- 前端工程师,正在给项目搭 E2E 测试。
- 测试开发,想评估 Cypress 是否值得引入团队。
- 技术负责人,在对比 Cypress、Playwright、Selenium 哪个更合适。
- 刚接触测试自动化的新手,想找一个上手门槛低、反馈直观的工具。
读完本文,你会了解 Cypress 的核心机制、写出一批可维护的测试用例、知道它有哪些坑、也清楚在 CI 里怎么稳定地跑起来。
2. Cypress 的核心概念与工作原理
2.1 Cypress 是什么
Cypress 是一个前端端到端测试框架,同时也提供了组件测试能力。它由 cypress-io 组织维护,在 GitHub 上是目前最流行的前端测试工具之一。
它解决的是“真实用户操作路径验证”的问题。单元测试验证一个函数、一个组件的输入输出,E2E 测试则模拟用户真正打开浏览器、点击按钮、填写表单、跳转页面这个过程,验证整个应用链路是否正常。
2.2 和 Selenium、Playwright 的架构差异
理解 Cypress 最好用的方式,是和 Selenium 对比着看。
Selenium 的工作方式:
- 测试代码运行在 Node、Java、Python 等进程中。
- 通过 WebDriver 协议向浏览器发送指令。
- 指令进入浏览器,执行操作,再返回结果。
- 每一步都是跨进程通信,所以慢,也容易因为时序问题不稳定。
Cypress 的工作方式:
- 测试代码由 Cypress 内置的浏览器执行。
- 测试代码和被测应用运行在同一个 JavaScript 环境下。
- Cypress 使用
document和window事件来感知应用状态。 - 命令通过一个内部的命令队列执行,并自动重试。
用一个比喻来理解:Selenium 像一个遥控器,隔着几米远控制汽车,每按一下按钮都要等汽车反馈;Cypress 像是直接坐在驾驶座上,手和方向盘连在一起,汽车状态一清二楚。
这个差异的意义不只是性能,而是可靠性。在 Cypress 里,当你执行cy.get('.btn').click()时,Cypress 不是发送一个指令然后等一个结果,而是先检查这个元素是否存在、是否可见、是否可交互,如果条件不满足,就持续重试,直到元素可用或超时。这让测试代码天然免疫了不少时序问题。
2.3 自动等待机制
Cypress 的自动等待是最容易被低估的设计。大多数测试框架里,等待是你自己要处理的事情:sleep(1)、WebDriverWait、poll都需要显式编写。而在 Cypress 中,绝大多数命令会自动等待元素出现、可见、可点击。
但这带来的新问题是:很多新手以为 Cypress 万无一失,于是不再关心异步逻辑,结果遇到“元素存在但接口还没返回”的场景还是会踩坑。Cypress 的自动等待是重试机制,不是魔法。它不代表网络请求会立刻完成,也不代表一个setTimeout会立刻执行完。正确理解:Cypress 会一直重试 cmd 的前置条件,直到 element 存在且满足可交互状态。
官方文档里提到了两个重要机制:retry-ability和actionability。前者是命令失败后自动重试,后者是操作之前检查元素是否可见、是否被遮挡、是否未 disabled。理解了这两个概念,很多 Cypress 行为就说得通了。
2.4 命令队列与异步模型
Cypress 的 API 是链式调用,比如:
cy.get('input') .type('hello') .should('have.value', 'hello');这不是普通的 Promise 链,而是一个命令队列。Cypress 会先收集所有命令,然后按顺序执行。每个命令执行完不会立刻返回结果,而是把结果交给队列中的下一个命令。这意味着你无法写成下面的形式:
const value = cy.get('input').val(); // 错误因为cy.get('input')返回的不是 DOM 元素,而是一个Chainer对象。想要拿到值,必须用.then()或者.should():
cy.get('input').then(($input) => { const value = $input.val(); // 在这里使用 value });这个设计让 Cypress 的测试代码看起来像同步代码,但实际是异步执行。新手最容易犯的错误,就是试图用写普通 JavaScript 的方式去拿 Cypress 命令的返回值。后面会专门展开。
3. Cypress 环境搭建与基础配置
3.1 环境准备
Cypress 是一个 Node.js 应用,所以环境要求很直接:
- Node.js 16 或以上版本(具体版本以官方文档和项目要求为准)。
- npm 或 yarn 或 pnpm 任选一种包管理器。
- 一个浏览器。Cypress 自带 Electron 浏览器,也可以配置 Chrome、Edge、Firefox。
如果你还没有 Node.js 环境,建议先安装最新的 LTS 版本。Cypress 对 Node 版本的要求并不苛刻,但太老的环境会导致安装失败或运行异常。
3.2 安装 Cypress
在项目根目录执行:
npm init -y npm install cypress --save-dev安装完成后,打开 Cypress 的图形界面:
npx cypress open首次运行会引导你生成cypress.config.js配置文件和cypress/目录。目录结构大致如下:
cypress/ ├── downloads/ ├── e2e/ │ └── spec.cy.js ├── fixtures/ │ └── example.json ├── screenshots/ └── support/ ├── commands.js └── e2e.js各目录的职责:
e2e/:放 E2E 测试用例,文件命名通常是xxx.cy.js或xxx.cy.ts。fixtures/:放测试用的 mock 数据,比如接口返回的 JSON。support/:放自定义命令、全局钩子、公共逻辑。screenshots/和downloads/:放运行产生的截图和下载文件。
3.3 基础配置文件
Cypress 的配置文件是cypress.config.js,比如:
const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:3000', supportFile: 'cypress/support/e2e.js', specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', viewportWidth: 1280, viewportHeight: 720, defaultCommandTimeout: 4000, }, });关键配置项说明:
| 配置项 | 作用 | 建议值 |
|---|---|---|
baseUrl | 测试应用的基础地址 | 指向本地开发服务器 |
specPattern | 测试文件匹配规则 | 默认cypress/e2e/**/*.cy.{js,jsx,ts,tsx} |
viewportWidth/viewportHeight | 浏览器窗口尺寸 | 按项目适配需求设置 |
defaultCommandTimeout | 命令默认超时时间 | 4000ms 起步,网速差可适当调大 |
retries | 失败重试次数 | CI 环境建议开 2 次 |
配置完先别急着写测试,启动本地开发服务器,确保baseUrl指向的应用能访问。
4. Cypress 第一个测试用例:注册登录流程
4.1 从用户视角描述场景
写 E2E 测试最忌讳的是“为写而写”。建议先把用户的核心路径画出来,比如:
- 用户打开首页。
- 点击“登录”按钮。
- 输入邮箱和密码。
- 点击提交。
- 登录成功后跳转到个人中心。
这个场景对应测试用例就是:验证从首页到个人中心的完整页面跳转和数据展示。先用最小路径跑通,再逐步覆盖更多分支。
4.2 编写测试文件
在cypress/e2e/目录下新建login.cy.js:
describe('登录流程', () => { beforeEach(() => { cy.visit('/'); }); it('用户应该可以成功登录', () => { cy.contains('登录').click(); cy.url().should('include', '/login'); cy.get('[data-cy="email"]').type('test@example.com'); cy.get('[data-cy="password"]').type('password123'); cy.get('[data-cy="submit"]').click(); cy.url().should('include', '/dashboard'); cy.contains('欢迎回来').should('be.visible'); }); });这里用到了>npx cypress run --browser chrome
运行成功会输出一组绿色的对勾。运行失败会显示具体断言错误和页面截图。
4.4 测试文件组织建议
一个项目不会只有一个测试文件。建议按功能模块组织:
cypress/e2e/ ├── auth/ │ ├── login.cy.js │ └── register.cy.js ├── dashboard/ │ └── overview.cy.js ├── cart/ │ └── checkout.cy.js └── common/ └── navigation.cy.js这样组织的好处是,某个模块报错时能快速定位范围,也方便在 CI 里按目录拆分并行执行。
5. Cypress 核心 API 详解
Cypress 的 API 设计非常统一,核心就两类:查询命令和断言命令。
5.1 元素查询
最常用的是cy.get(),按 CSS 选择器查找:
cy.get('button') // 获取所有 button cy.get('.btn-primary') // 获取 class 包含 btn-primary 的元素 cy.get('[data-cy="submit"]') // 获取>cy.contains('登录') // 查找包含“登录”文本的元素 cy.contains('button', '登录') // 查找包含“登录”文本的 buttoncy.get()和cy.contains()的区别:get以选择器为主,contains以文本为主。通常先用get定位容器,再用contains定位文本。
在表单场景,还可以用cy.get('form').within()缩小范围:
cy.get('[data-cy="login-form"]').within(() => { cy.get('input[name="email"]').type('test@example.com'); cy.get('input[name="password"]').type('password123'); cy.get('button').click(); });5.2 与元素交互
Cypress 的交互命令包括:
click():点击。type():输入文本。select():选择下拉框选项。check()/uncheck():勾选/取消勾选复选框。trigger():触发事件。scrollIntoView():滚动到可见区域。
例如:
cy.get('[data-cy="search-input"]') .type('手机') .should('have.value', '手机'); cy.get('[data-cy="category"]') .select('电子'); cy.get('[data-cy="agree-checkbox"]') .check(); cy.get('[data-cy="submit-btn"]') .scrollIntoView() .click();type()有个细节:它支持输入{enter}这类特殊键:
cy.get('[data-cy="search-input"]') .type('query{enter}');5.3 断言机制
Cypress 的断言既可以用 BDD 风格的should(),也可以用expect()配合 Chai 断言库。
should()的常见用法:
cy.get('[data-cy="title"]') .should('have.text', '商品列表'); cy.get('[data-cy="item"]') .should('have.length', 5); cy.get('[data-cy="btn"]') .should('be.visible') .and('not.be.disabled'); cy.contains('加载中') .should('not.exist');expect()的用法通常是配合.then()或者cy.wrap():
cy.wrap([1, 2, 3]) .should('deep.equal', [1, 2, 3]); cy.get('[data-cy="count"]').then(($el) => { expect(parseInt($el.text(), 10)).to.be.greaterThan(0); });should()和expect()的本质区别:should()会延迟断言并配合重试机制,expect()则立即执行断言,不重试。所以在断言元素状态时优先用should()。
5.4 网络请求控制与等待
在 E2E 测试中,最不稳定的因素就是网络请求。Cypress 支持拦截 XHR 和 Fetch 请求,并给它们起别名:
cy.intercept('GET', '/api/products').as('getProducts'); cy.visit('/products'); cy.wait('@getProducts').then((interception) => { expect(interception.response.statusCode).to.eq(200); expect(interception.response.body.length).to.be.greaterThan(0); });这个能力极其有用。它让测试不再靠“盲等”,而是明确知道某个接口返回后再继续断言:
cy.get('[data-cy="products"]').should('contain', '手机');配合cy.wait('@getProducts'),能避免接口还没返回时就去断言页面内容导致的误报。
5.5 请求 Mock
前端开发中,接口经常不稳定或者还没实现。Cypress 支持直接 mock 响应:
cy.intercept('GET', '/api/user', { statusCode: 200, body: { id: 1, name: '测试用户', email: 'test@example.com', }, }).as('getUser');这样前端测试就不依赖真实后端,可以在前端代码稳定时跑通完整测试。
6. 异步管理与等待的正确姿势
6.1 为什么 Cypress 的等待不容易出问题
Cypress 的命令默认带有重试机制。cy.get()找不到元素时不会立刻报错,而会在超时时间内反复尝试查找。这让测试代码里几乎不需要写显式的sleep。
但实际项目里,我们经常遇到“元素存在但内容还没加载完”的场景。比如一个列表,div已经渲染了,但里面的数据还是空的。这时候cy.get('[data-cy="list"]')会成功,但cy.get('[data-cy="list-item"]')可能失败,因为数据还没回来。
解决办法不是加wait(1000),而是等待具体的数据条件满足:
cy.get('[data-cy="list-item"]') .first() .should('contain', '商品');或者等待网络请求完成:
cy.intercept('GET', '/api/products').as('getProducts'); cy.visit('/products'); cy.wait('@getProducts'); cy.get('[data-cy="list-item"]').should('have.length.at.least', 1);6.2 尽量避免硬编码等待
cy.wait(1000)这种写法在 Cypress 社区被广泛视为坏味道。原因很直观:固定的等待时间无法适配多变的环境。本地网络快,CI 网络慢,1000ms 在本地够,在 CI 可能不够。一旦不够,测试就挂得莫名其妙。
更合理的替代方案:
- 等待元素可见:
cy.get().should('be.visible')。 - 等待文本出现:
cy.contains().should('be.visible')。 - 等待请求完成:
cy.wait('@alias')。 - 等待元素消失:
cy.get().should('not.exist')。
如果某些场景不得不用固定等待,尽量缩短时间,并注释说明原因。
6.3 异步请求的错误等待示例
再看一个容易踩坑的写法:
it('搜索商品', () => { cy.visit('/'); cy.get('[data-cy="search"]').type('手机{enter}'); cy.wait(2000); // 错误:不应该硬等 cy.get('[data-cy="result"]').should('contain', '手机'); });改成推荐写法:
it('搜索商品', () => { cy.intercept('GET', '/api/search*').as('search'); cy.visit('/'); cy.get('[data-cy="search"]').type('手机{enter}'); cy.wait('@search'); cy.get('[data-cy="result"]').should('contain', '手机'); });6.4 Cypress 命令不是 Promise
这是新手最常困惑的点。Cypress 链式调用的命令返回的不是 Promise,而是一个Chainable对象。直接在命令后面用await是不行的:
// 错误 const el = await cy.get('button'); el.click();正确做法是:
cy.get('button').click();如果需要拿到命令结果,用.then():
cy.get('[data-cy="count"]').then(($el) => { const count = parseInt($el.text(), 10); cy.log('当前数量:', count); });如果需要做条件逻辑,也可以用 Cypress 提供的cy.its()、cy.invoke()来简化:
cy.get('[data-cy="input"]') .invoke('val') .then((value) => { // value 就是 input 的值 });7. Cypress 数据驱动测试与自定义命令
7.1 用 fixture 管理测试数据
E2E 测试里会用到大量测试数据,比如用户名、邮箱、密码、商品信息。建议统一放在cypress/fixtures/目录下的 JSON 文件里。
cypress/fixtures/user.json:
{ "email": "test@example.com", "password": "password123", "name": "测试用户" }测试中加载 fixture:
describe('用户信息', () => { it('显示用户信息', () => { cy.fixture('user').then((user) => { cy.get('[data-cy="user-name"]').should('contain', user.name); cy.get('[data-cy="user-email"]').should('contain', user.email); }); }); });如果数据类别很多,也可以把多个 fixture 组合成一个对象管理。
7.2 自定义命令
cypress/support/commands.js文件支持添加全局自定义命令。这能显著减少重复代码。
比如登录操作在很多测试里都要执行:
Cypress.Commands.add('login', (email, password) => { cy.visit('/login'); cy.get('[data-cy="email"]').type(email); cy.get('[data-cy="password"]').type(password); cy.get('[data-cy="submit"]').click(); cy.url().should('include', '/dashboard'); });然后在测试里:
describe('个人中心', () => { beforeEach(() => { cy.login('test@example.com', 'password123'); }); it('展示订单列表', () => { cy.get('[data-cy="order-item"]').should('have.length.at.least', 1); }); });自定义命令不只是节省代码,更重要的是统一测试操作的标准。登录逻辑一旦变化,只需要改一个地方。
需要注意,commands.js文件的修改需要 Cypress 重启才会生效,因为它在运行前被加载到测试运行器环境中。
8. Cypress 集成 CI 与 Cypress Cloud
8.1 在 CI 中运行
Cypress 集成 CI 的基本思路:
- 安装依赖。
- 启动本地服务(或者用 Cypress 自带的
start-server-and-test)。 - 运行
cypress run。 - 上传测试报告和截图。
一个常用的 npm 脚本配置:
{ "scripts": { "test:e2e": "cypress run", "test:e2e:chrome": "cypress run --browser chrome" } }GitHub Actions 的定义示例:
name: E2E Tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci - name: Run Cypress run: npx cypress run如果项目不是纯前端,还需要同时启动后端服务,一般用start-server-and-test:
{ "scripts": { "ci:e2e": "start-server-and-test dev http://localhost:3000 test:e2e" } }8.2 测试报告与截图
CI 里测试失败后,Cypress 会自动截图。通过cypress run --reporter junit可以生成 JUnit 格式报告,方便接入 Jenkins 等平台。
失败视频默认也保留。在 CI 流水线中,把cypress/videos和cypress/screenshots作为 artifact 上传,能极大提升排查效率。
8.3 Cypress Cloud 的价值
Cypress Cloud 是 Cypress 官方提供的云端服务,主要解决三个问题:
- 查看测试运行历史和趋势。
- 并行执行测试,尤其是大量测试时能明显减少排队时间。
- 失败时可以关联到 GitHub 提交记录,快速定位是从哪个 commit 开始引入的问题。
它的cypress run会生成一个record key和run url,上传到云端后,团队成员可以共享测试结果。
可以简单这样理解 Cypress Cloud:本地跑测试是给自己看,云端跑测试是给团队看。它把测试从“本地脚本”提升为“团队测试资产”。
不过我提醒一下,Cypress Cloud 是商业服务,免费额度有限。个人项目可以先用本地结果,团队协作再考虑上云端。
9. Cypress 常见问题与排查思路
9.1 元素定位不到
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
cy.get()超时 | 元素尚未渲染 | 打开 DevTools 查看网络请求和 DOM | 增加自动等待,或使用cy.intercept().wait() |
| 元素被遮挡无法点击 | 其他元素覆盖在目标元素上面 | 检查是否有遮罩层、loading 层 | 先关闭弹窗,或使用{force: true} |
| 元素存在于多个 iframe | Cypress 默认访问不到 iframe 内容 | 检查元素是否在 iframe 内 | 使用cy.iframe()或重构页面结构 |
| 动态 class 导致选中失败 | 元素 class 是运行时生成的 | 查看 DOM 实际 class | 改用>beforeEach(() => { cy.clearCookies(); cy.clearLocalStorage(); cy.window().then((win) => { win.sessionStorage.clear(); }); });9.3 Cypress 打开空白页遇到这种情况,先确认 9.4 测试代码里无法直接访问应用的 window 对象Cypress 的测试代码虽然运行在浏览器里,但应用的 9.5 Cypress 无法访问第二页面前面提到过,Cypress 对多标签页的支持有限。当点击一个 如果需要验证链接地址: 10. 最佳实践与工程建议10.1 测试选择器规范这条怎么强调都不过分。尽量使用稳定的测试属性,而不是 class、id 或可见文本。 推荐: 因为 class 改动频繁,文本可能被国际化替换。统一维护一套 这种“测试数据即服务”的方式,比在测试里穿插大量 UI 操作创建数据,要稳定得多。 10.5 失败重试策略CI 环境里测试偶尔失败可能是环境不稳定导致。可以配置自动重试:
10.6 在本地开发阶段就接入 Cypress不要等 CI 阶段才跑 E2E。本地提交代码之前,跑一遍关键测试能提前发现很多回归问题。可以在 Git hooks 里挂一个轻量检查: 但注意,E2E 测试跑得慢,不建议每次 commit 都跑,pre-push 或 CI 上跑更合理。 11. 总结与实践建议Cypress 真正值得推荐的原因,不是它比 Selenium 多了多少 API,而是它改变了前端 E2E 测试的开发体验。自动等待、时间旅行、request interception、零配置调试,这些能力叠加起来,让写 E2E 测试的阻力明显变小。 不过任何工具有其边界。Cypress 擅长的是现代 Web 应用、SPA 项目、前后端分离架构。如果你的应用大量依赖 iframe、多窗口、非常规的浏览器行为,Cypress 用起来会感到别扭。遇到这类场景,先做好评估,不要为了用工具而去硬套。 以工程实践角度,我建议这样推进:
|