从体验、原理到工程落地,Cypress 让前端 E2E 测试真正可用
2026/8/30 11:54:26 网站建设 项目流程

第一次真正意识到 Cypress 的特殊之处,不是在我写完第一份端到端测试的时候,而是在我看着团队里一个搁置了三个月的 Selenium 项目被重新捡起来的那一刻。那个项目没有换人,没有重写业务代码,只是把跑测试、看报错、定位元素这一整条链路换到了 Cypress 上。前端团队对 E2E 测试的态度,也从“知道该做但不想碰”,变成了“测一下也没多麻烦”。

有人把 Cypress 看成又一个 E2E 框架,但真正用一阵子之后会发现,它解决的不是“能不能自动化”的问题,而是“跑挂了之后你能不能快速知道为什么”的问题。后者才是绝大多数前端项目真正缺的东西。这篇文章我想从体验、原理、工程落地三个角度,把自己用 Cypress 过程中沉淀下来的理解写清楚。

1. 为什么前端端到端测试,过去一直让人想跳过

1.1 测试代码不是最难写的,最难的是“等”和“查”

如果你写过传统的浏览器自动化测试,一定对这类片段不陌生:先 sleep 三秒等页面加载,再 sleep 两秒等接口返回,然后才敢点击下一个按钮。问题在于,网络请求和页面渲染的完成时间在大多数情况下不是一个固定值。sleep 短了,测试在慢环境里就会不稳定;sleep 长了,整个测试套件跑一遍要拖成十分钟起步。

这不是写测试的人偷懒,而是旧架构从根上决定的:测试进程和被测页面处于两个互相隔离的运行环境里。测试代码只知道“我发出去了一个指令”,但无法准确知道页面那一边到底发生了什么。于是只能用固定时间来猜,猜不中就换更大的时间。越复杂的页面,这种等待越多,测试脚本自然就越臃肿,越让人不想维护。

1.2 失败之后,定位问题的时间比写测试还多

很多团队不是没尝试过端到端测试,而是写了一批用例之后发现:测试经常红,而且红得莫名其妙。

常见画面是:CI 上某个用例失败了,打开日志看到一句element not interactable,或者timeout waiting for element。你根本不知道是页面没加载出来,还是元素选择器写错了,还是上一个用户的操作污染了状态。更麻烦的是,本地开发和 CI 环境不一样,本地跑一遍可能是通过的,CI 上却稳定复现失败。你只能到处加截图、加日志、再跑一次看结果。

这个过程重复几次之后,团队就会本能地开始不信任这套测试。最后的结果往往不是把测试修好,而是把失败断言先注释掉,或者直接砍掉整个端到端测试的 CI 任务。写测试是为了省时间,最后反而成了时间黑洞。

1.3 Cypress 换了一个出发点:先解决反馈循环

Cypress 想清楚了一件事:与其让测试脚本努力去控制一个外部浏览器,不如让测试运行在浏览器里,和应用共享同一个运行时。

这意味着 Cypress 可以实时感知页面里的 DOM 变化、网络请求、对话框、甚至 JavaScript 报错。它不需要靠 sleep 去猜页面什么时候稳定,而是通过自动重试去等待应该出现的条件。更重要的是,每一步测试执行完,Cypress 都会在命令行面板里留下快照,你可以用鼠标悬浮到每一步上去看当时页面长什么样。

这套设计的本质,是把“测试失败后定位问题”的成本降到了接近零。它让端到端测试不再只是交付前的验收动作,而是开发过程中可以随时打开、随时调试、随时定位问题的工具。

2. Cypress 的架构选择,决定了它的体验上限

2.1 “跑在浏览器里”不是一个实现细节,而是一个产品策略

Selenium 的思路是通过 WebDriver 协议,从外部进程向浏览器发起命令。这个方案成熟,跨语言、跨浏览器,但代价是外部驱动者和浏览器内部状态之间存在一层协议隔膜。你看到的页面状态,始终是“间接获得”的。

Cypress 反过来,测试代码直接运行在浏览器里,和被测应用同一个事件循环。这意味着 Cypress 可以:

  • 等待某个元素出现时,直接轮询 DOM,而不是通过远程命令一问一答。
  • 监听未捕获的 JavaScript 异常,并且在测试失败时把异常信息直接展示出来。
  • 拦截网络请求,因为所有网络请求都发生在同一个浏览器上下文中。

这个差异是 Cypress 流畅体验的根源,也是它某些能力边界的根源。它本质上不是一个“浏览器控制器”,而是一个“应用增强器”。

2.2 自动等待:用“重试”代替“sleep”

Cypress 里几乎所有对界面状态的断言,都会默认自动重试。

例如cy.get('.user-name').should('have.text', '张三'),如果第一次检查时文本还不正确,Cypress 会反复查询和校验,直到条件满足或超时。这意味着绝大多数异步渲染场景不需要手写等待逻辑。你不再需要去想“接口还要多久才能回来”,只要说清楚“最后应该是什么样子”,剩下的交给 Cypress。

这个设计降低了编写成本,也降低了维护成本。团队里新手接手测试时,不太容易出现“不知道该 sleep 多久”的困惑。

2.3 网络层拦截:让测试数据变成一个可控变量

端到端测试最怕外部依赖不稳定:后端返工、测试库被改、第三方接口超时。Cypress 的cy.intercept()允许你在浏览器层面直接拦截特定请求,并且返回你指定的数据。

这样一来,你可以做到:

  • 同一个测试用例,每次跑都能返回同一份用户数据。
  • 不用后端专门为测试准备一套环境。
  • 可以模拟异常状态,比如 500、超时、空数据,来验证前端异常处理是否符合预期。

从工程实践来看,这个能力把 E2E 测试从“依赖整个系统稳定”的脆弱状态,变成了“前端自己可控”的状态。它是 Cypress 用例在 CI 上能稳定运行的重要基础。

2.4 时间旅行调试:失败不再是一堆日志

Cypress 的命令执行列表里,每一步都会留下一个页面快照。执行到哪一步,页面状态就是什么样子。鼠标悬停在任意一步上,能看到当时页面上的输入框内容、下拉框状态、网络请求等。

这个体验非常接近前端开发里的调试工具。以前测试失败后要反复“读日志猜现场”,现在可以直接“回到现场看状态”。团队协作时,同事抛出来一条 Cypress 失败记录,你点几次鼠标就能明白问题出在用户操作序列的哪一环,而不是把一堆截图发来发去。

3. 先跑通一个最小可用测试:从安装到断言

3.1 环境准备:只需要一个普通前端项目

Cypress 对新手友好的一个地方在于,它不要求你先搭一套复杂的测试服务。你只要有 Node.js 项目,安装依赖后,Cypress 可以直接打开自己的图形化运行器。

npm init -y npm install cypress --save-dev

如果你用 pnpm 或 yarn,也可以对应地执行pnpm add cypress -Dyarn add cypress --dev。首次启动时,建议先打开图形化界面,它会在第一次运行时自动生成默认配置:

npx cypress open

打开之后,Cypress 会生成一套标准的项目结构。需要留意的是,不同版本的默认目录有差异:新版(v10 之后)默认把测试文件放在cypress/e2e/下,旧版则是cypress/integration/。如果本地生成的结构和网上教程不一致,先确认 Cypress 版本再调整路径,别急着复制粘贴。

3.2 编写第一个测试:别急着选元素,先想清楚怎么定位

以最简单的登录流程为例,常见写法是这样的:

describe('登录流程', () => { beforeEach(() => { cy.visit('/login') }) it('输入正确凭证后跳转到首页', () => { cy.get('[data-testid="username"]').type('tester') cy.get('[data-testid="password"]').type('123456') cy.get('[data-testid="submit"]').click() cy.url().should('include', '/dashboard') cy.contains('欢迎回来').should('be.visible') }) })

这里最值得关注的是选择器。很多人在前面几轮写测试时最喜欢直接用类名或 text,比如cy.get('.btn-primary')cy.contains('登录')。在页面还比较简单时,这确实能跑通。但一旦组件库升级、样式重构或者国际化文案调整,这些选择器就会集体失效。

我更建议从第一天就统一使用>cy.intercept('GET', '/api/user/profile', { fixture: 'profile.json' }).as('getProfile') cy.visit('/profile') cy.wait('@getProfile') cy.get('.user-name').should('have.text', '测试用户')

这样测试用的数据完全由 fixture 文件控制,你可以预先准备各种边界状态的数据:空列表、异常状态、超时、超大字段等。对前端团队来说,这就像前端开发时已经习惯用的 mock 层,只不过被 Cypress 接到了整个测试流程里。

3.4 不要写一堆 if 条件判断状态

刚接触 Cypress 的人容易把它当成普通编程来写,比如在测试里写if (cy.get(...).should(...)),或者用变量去存元素状态。这种做法通常会让 Cypress 的自动等待和重试机制失效。

Cypress 的推荐做法是:用链式命令和断言表达预期,而不是用程序逻辑去分支。如果场景确实复杂,应该考虑把多分支拆成多个独立的测试用例,每个用例只覆盖一条明确路径。这样失败时定位更精准,维护起来也更容易理解。

4. 工程化落地:单条用例跑通只是开始

4.1 测试策略分级:先测什么,比测多少更重要

一个常见误解是:端到端测试覆盖率越高越好。实际落地时,覆盖所有页面往往会让维护成本爆炸。

更好用的分层思路是:

  • 冒烟测试:核心链路能否跑通。比如登录、首页加载、关键列表展示。
  • 核心链路测试:用户最常走的关键路径。比如下单流程、发布流程。
  • 回归测试:涉及历史功能的关键回归点。不需要追求每个按钮都覆盖。

从工程经验看,我会建议先只覆盖一类:团队最痛、每次发版前都要手动点的那条路径。把它变成自动化用例之后,团队会立刻感受到“不用手动重复点一遍”的价值,然后再慢慢扩展。

4.2 在 CI 里稳定运行的三要素

很多团队在本地跑 Cypress 一切正常,一上 CI 就各种挂。复盘下来,问题大多出在三件小事上:

  1. 数据要可控。测试用例不应该依赖某一个开发环境里才能登录的账号。优先通过接口 mock、测试数据工厂或独立测试库来保证输入数据每次都相同。
  2. 测试要相互独立。不要让用例 A 依赖用例 B 之前设置的数据。Cypress 默认会清空 cookies 和 localStorage,但如果你自己维护了数据库状态或全局变量,仍然会造成污染。
  3. 要关心并发资源。如果是多 worker 并行跑测试,容易遇到资源竞争,比如同一份测试数据被多个任务同时写入。要么隔离数据,要么在 CI 里减少并行度。

CI 里使用cypress run时,可以按自己的习惯分组执行:

npx cypress run --spec "cypress/e2e/**/*.cy.js"

默认情况下,Cypress 在run模式下会记录视频,失败时会自动截图。不需要这些产物时,可以在配置里关掉,以减少 CI 时间。

4.3 让测试自己告诉你在哪失败

好的测试失败信息应该一眼能看懂。Cypress 给了几个能力来保证这一点:

  • 给等待的请求起别名,比如.as('getProfile')
  • should()写断言时,描述清楚预期。
  • 在关键操作前用cy.get('...').should('be.visible')做显式等待,减少因为页面还在渲染导致的误报。

很多人会因为怕测试太啰嗦,而不写这些前置断言。但实际运维时,这些断言能让测试失败时人类一秒钟定位到业务语义,而不是面对一个“元素找不到”的原始状态。

4.4 长期维护:最怕的不是测试挂掉,而是测试没人敢动

端到端测试最大的风险不是初期搭建,而是半年后的维护。如果测试因为选择器脆弱、数据不稳定而频繁变红,大家就会习惯性忽略它,然后测试慢慢失去价值。

所以我会建议,在项目里把“测试能不能被快速重跑、失败信息能不能快速定位”当成比“覆盖率”更重要的指标。如果某条测试经常不稳定,说明它本身的数据控制或选择器设计有问题,应该尽早修复,而不是在失败列表里让它躺着。

5. 最容易踩的坑,和一套排查链路

5.1 脆弱选择器是测试崩掉的第一个来源

E2E 测试之所以让别人觉得“不靠谱”,大部分来自选择器太脆:

  • 使用动态生成的 id,比如#user-123
  • 使用样式 class,比如.ant-btn-primary
  • 使用页面文案的完全匹配,比如cy.contains('立即提交'),但文案变成了“重新提交”。

这些选择器在页面改版时几乎必然失效。更稳定的做法是给测试专用属性,或者使用相对稳定的语义定位。页面上逻辑重要、经常被测试引用的元素,最好拥有唯一且稳定的>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询