
翻出上个月写的自动化测试代码里面一排cy.wait(5000)让我心里直发毛。当时为了等一个弹窗出现写死了五秒弹窗刚过两秒渲染出来测试误报失败弹窗慢了十来秒测试又在那儿空转白白浪费时间。把等待写死是我见过的端到端测试最容易变脆的原因。而 Cypress 这套测试框架恰恰就是从根上解决这类问题来的——它不要求你手工设计一堆 sleep而是用自动重试和命令队列帮你把测试从“定时炸弹”变成“真正可维护的代码”。这篇内容不打算讲高深理论而是把“*Cypress”这个标题的星号展开——你可以把它理解成“不限版本、从入门到工程化的全量笔记”。我会从为什么我要换掉老一套自动化测试路径讲起到第一个真实用例怎么落地再到异步数据、动态页面、持续集成里的那些隐蔽的坑最后分享我现在写测试的一些习惯。适合正在评估测试框架的团队也适合已经在用 Cypress 但总感觉测试不稳定的人。1. 不要再用“脚本化的等待”去写端到端测试了1.1 我为什么放弃了老一套自动化测试路径早几年做 UI 自动化我一直在跟一套以 Selenium 为核心的工具链打交道。说实话它能跑通但维护成本高得吓人。页面加载慢脚本里就得塞Thread.sleep或者time.sleep某个异步接口晚了一秒返回断言就失败想跑回归还得先启动一个独立的 WebDriver 服务。最难受的是测试代码写到最后变成了“时间管理代码”——左边一个waitForElement右边一个waitForAjax真正验证业务逻辑的部分反而没多少。某次我在一个后台管理项目里写了二十多条端到端用例每轮回归跑完大概四十分钟其中至少有十分钟浪费在等待上而且只要上游接口抖动一下失败率能冲到百分之六十。上线前一天才临时排查哪些失败是真 bug、哪些是等待不够这个过程极度消耗信心。后来 A 同事把 Cypress 引入到项目里做对比我才发现原来端到端测试的主路径是可以不开 WebDriver、不用写 sleep、甚至失败时还能自动回放整个操作过程的。1.2 Cypress 的自动等待和重试机制到底在解决什么Cypress 和传统自动化工具最大的区别在我看来是它的执行模型。它不是在外部控制一个浏览器做远程调用而是直接跑在浏览器里的一个代理进程里所有命令都被放入一个队列按顺序自动执行。更关键的是它对“等待”有一套内置的默认策略比如cy.get一个元素如果元素还没有出现它不会马上失败而是反复发起查询直到元素出现或超过超时时间。我一开始对这种机制将信将疑传统脚本如果没找到元素不是应该立刻报错吗后来用多了才明白这其实是对的。页面渲染本来就是异步的用户可以在页面慢慢出现的过程中继续操作为什么测试脚本就非要掐着秒表去算时间呢Cypress 的做法可以理解成它帮你默认带了一点“耐心”而且在断言失败之前会不断重试。于是测试代码里不再需要到处写“等到什么出现”这种前置逻辑只要写下“我希望页面上有这个内容”Cypress 自己会等到那一幕发生。这就把测试的关注点从“时间”挪到了“状态”。我在日常写用例时几乎不再关心页面元素是 0.5 秒还是 5 秒后出现只关心它最终一定会出现。这让用例稳定很多也让失败原因更接近真实如果超时了还找不到要么是选择器写错要么是页面真的有 bug。1.3 与 Playwright 对比为什么我最终选了 Cypress可能有人会问现在 Playwright 也挺热为什么要选 Cypress我个人的看法是两者解决的是不同偏好下的问题。Playwright 的跨浏览器能力和多标签页控制更强在需要覆盖全浏览器矩阵的项目里非常有优势。但 Cypress 的优势在于“开发体验闭环”和调试效率。对比维度Cypress传统自动化方案以 Selenium 为代表架构在浏览器内运行命令队列自动重试外部 WebDriver 控制浏览器等待方式自动等待 断言重试通常需要手动 sleep / 显式等待调试体验时间旅行、快照对比、失败视频回放通常靠断点与日志网络控制内置cy.intercept拦截请求需要额外工具或中间层代理安装依赖npm 安装即可无需额外服务需配置驱动与浏览器版本匹配这里不是要否定其他框架而是想说明如果你和我一样日常项目以单页应用为主测试环境相对可控那么 Cypress 带来的体验提升是非常明显的。它牺牲了一部分“什么浏览器都能测”的野心换来了更稳定的编写体验和更简单的调试路径。我最终决定选它就是因为在某次尝试中我用二十多行代码就完成了之前需要两三百行代码才能写完的核心流程测试而且第一次跑就全绿。2. 从安装到第一条可以在 CI 里跑起来的测试2.1 初始化项目与 cypress.config.js 中最容易被忽略的配置Cypress 的安装本身很简单在一个前端项目里执行安装命令就行这里我用 npm 作为示例npm install cypress --save-dev装完以后打开它有两种方式带界面的测试运行器或者纯命令行执行。我平时开发时用带界面的运行器可以边写边看每一步对应的页面快照跑回归时则完全走命令行的无头模式在流水线里统一调用。第一次启动 Cypress 后它会在项目里生成一个cypress/目录和配置文件。新版默认是cypress.config.js里面有一些和“能不能在 CI 里稳定运行”直接相关的字段很多人会忽略掉module.exports defineConfig({ e2e: { baseUrl: http://localhost:8080, defaultCommandTimeout: 10000, video: true, viewportWidth: 1440, viewportHeight: 900, }, });其中defaultCommandTimeout我一般会从默认的 4 秒调到 10 秒。不是说鼓励测试无脑等待而是给比较慢的接口服务留出合理空间。baseUrl千万不要留空否则你在每一个cy.visit()里都要写完整域名切换环境时就得全局替换非常痛苦。设置了baseUrl之后测试脚本里只需要写cy.visit(/login)换环境时只改配置文件不影响用例代码。2.2 用购物车结算流程写一个完整测试用例理论东西说太多不如跑一个实际例子。我经常用一个“购物车结算”的流程来演示端到端测试的核心路径用户登录、选择商品、进入购物车、提交订单、看到成功页。这基本覆盖了表单、按钮、路由跳转、接口请求、页面反馈几种最常见场景。describe(购物车结算流程, () { beforeEach(() { cy.visit(/login); cy.get([data-cyusername]).type(tester); cy.get([data-cypassword]).type(123456); cy.get([data-cysubmit]).click(); cy.url().should(include, /home); }); it(用户可以将已添加的商品成功下单, () { cy.get([data-cyproduct-item]).first().click(); cy.get([data-cyadd-to-cart]).click(); cy.get([data-cycart-badge]).should(contain, 1); cy.get([data-cygo-checkout]).click(); cy.get([data-cyconfirm-order]).click(); cy.get([data-cyorder-success]).should(be.visible); }); });这段代码里我没有写任何sleep但测试依然能稳定通过。原因就在 Cypress 的自动重试机制cy.get([data-cycart-badge]).should(contain, 1)会等徽标的文本变为 “1” 再继续进行而不是假定“点击之后立刻变化”。我写测试时最舒服的一点就是我只需要描述“最终应该是什么样”具体什么时候发生交给框架自己判断。2.3 断言与超时让测试出错时能告诉你真正的错误断言是测试的灵魂但很多新手写的断言方式会让排查问题变成一场灾难。比如只写cy.get(div).should(be.visible)失败时你只能知道某个 div 不可见至于它是订单提交按钮还是底部 footer完全无法从日志中判断。我现在的习惯是选择器尽可能语义化断言消息尽可能具体。比如下单成功页应该出现“感谢购买”的标题我就会写成cy.get([data-cyorder-success-title]) .should(be.visible) .and(contain, 感谢购买);这样如果失败日志会非常清楚地告诉你我在找一个>cy.wait(3000); // 等接口返回 cy.get([data-cyrow]).should(have.length, 10);这段代码的问题在于3 秒到了接口不一定返回接口返回了 3 秒也可能已经做了分页刷新。更关键的是cy.wait(3000)本身就是一种破坏性写法——它把测试从“基于状态的验证”退化成了“基于时间的猜测”。我在团队里做过约定任何测试代码中出现的裸cy.wait(数字)都必须被代码评审拦截。正确的替代方式是等某个元素出现、等某个接口完成、或者等某个文本可见。3.2 用 cy.intercept 与别名解决加载状态的验证问题动态数据的另一个痛点是验证“加载中”的状态很难写。比如页面刷新时会先显示一个 spinner再显示表格。如果直接断言表格可见等表单一出来测试就通过了可你没法确定用户是否真的“看到过”加载状态。这个问题在传统工具里几乎只能卡时间在 Cypress 里可以用拦截器配合别名解决。cy.intercept(GET, /api/products).as(getProducts); cy.visit(/products); cy.get([data-cyloading-spinner]).should(be.visible); cy.wait(getProducts); cy.get([data-cyloading-spinner]).should(not.exist); cy.get([data-cyproduct-row]).should(have.length.gt, 0);通过给接口请求取一个别名cy.wait(getProducts)会等待该请求完成然后我们再断言 spinner 消失。这种写法既不依赖固定的毫秒数又能精确覆盖加载过程的每一个状态。我在写涉及网络请求的用例时几乎都会用它来同步“请求完成”这一事件这是让测试稳定下来最有效的一招。注意cy.intercept的匹配模式很灵活可以精确匹配 URL也可以用通配符和正则。我建议在项目里给所有核心接口统一设计一套别名命名规则比如getProductList、createOrder、updateProfile这样看测试代码时等于同时在看一遍接口依赖关系。3.3 表格分页、日期控件、坐标点击等反直觉场景的处理真实业务总有些反直觉的控件它们往往也是测试用例最容易翻车的地方。比如表格分页如果每页只有十条数据你想验证“翻到第二页出现某条数据”就不能把数据创建逻辑放在测试开头否则页码初始状态不一致。更好的做法是在测试前置阶段直接调用接口造数据或者拦截列表接口并动态返回指定数据cy.intercept(GET, /api/orders?page2, { body: { items: [fakeOrder], total: 11 }, }).as(page2Orders);日期控件我特别想提醒一句不要用坐标点击来选日期。坐标属于像素级依赖任何样式调整都会导致测试失效。我在某次项目中吃过亏在日期控件的某个日历格子上用click(15, 20)指定坐标结果前端把弹层做了一点偏移测试立刻跟不上。Cypress 里推荐的做法是直接用invoke(val)去设置 input 的值或者先定位到具体某一天的可点击文本再用文本内容去选。3.4 视频回放和截图调试失败测试的正确姿势测试失败时最怕的是“不知道当时发生了什么”。Cypress 默认会录制测试执行的视频并且在失败时自动保存截图。我在实际项目里会把这个能力利用得非常透彻在关键步骤之后插入显式的截图命令比如下单成功后cy.screenshot(order-success)这样即使后面某一步挂了视频回放和截图也能准确还原当时页面长什么样。但也要注意视频机制在 CI 里吃磁盘空间比较快建议只保留失败视频成功用例的视频直接清理掉。我通常在流水线配置里加一个保留策略比如失败用例视频保留 7 天成功的不落盘。这样既能保证排查素材充分也不会让测试产物无限膨胀。4. 给测试加工程化能力自定义指令、配置环境与持续集成4.1 用 Cypress.Commands.add 封装登录与权限前置操作很多测试用例的开头都要前置地登录系统。如果每条用例都写一遍登录步骤代码会非常臃肿而且如果登录接口有变化维护成本直接翻倍。Cypress 提供的自定义命令就是干这个用的。// cypress/support/commands.js Cypress.Commands.add(login, (username tester, password 123456) { cy.session(login-session, () { cy.visit(/login); cy.get([data-cyusername]).type(username); cy.get([data-cypassword]).type(password); cy.get([data-cysubmit]).click(); cy.url().should(include, /home); }); cy.visit(/home); });注意我用了cy.session。它能缓存登录状态同一个测试文件里的多个用例跑完一遍登录后后续就不用重复走登录流程了速度提升非常明显。如果没有这个机制一个文件里十条用例就得登录十次跑一遍多花很多时间。自定义命令的核心价值是“把前置条件集中管理”后续一旦登录流程变了只改一个文件就够了。4.2 环境变量和 baseUrl 在多环境之间怎么切换项目测试经常要面对本地环境、测试环境、预发环境。如果你把域名硬编码在测试脚本里那么换环境就像改地雷一样很容易漏掉一两处。我现在的做法是把环境相关的信息全部收敛到环境变量和配置文件解耦。// cypress.config.js const baseUrlMap { local: http://localhost:8080, dev: https://dev.example-server.example, staging: https://staging.example-server.example, }; const env process.env.APP_ENV || local; module.exports defineConfig({ e2e: { baseUrl: baseUrlMap[env], }, });跑不同环境时只需要在命令前加一个环境变量APP_ENVstaging npx cypress run这种方式配合 CI 的多环境流水线特别好用。测试脚本里只依赖baseUrl不会出现“同一个用例在不同环境里跑出不同结果”的混乱局面。4.3 测试报告、视频与失败重跑在流水线里怎么设置持续集成里跑测试产出可不能只是“控制台日志”。我现在习惯至少保留两类产物测试报告和失败截图。测试报告我会用 Mochawesome 风格生成 HTML方便非技术人员也能看出哪条用例挂了、挂在哪一步。报告路径和视频路径都建议指定到明确的产物目录这样流水线里提取产物时会很方便。npx cypress run本身就支持失败重跑次数。在流水线里我会根据历史稳定性来调整这个参数如果用例本身很稳定只是 CDN 资源偶发加载慢我会给它一到两次重试机会如果是真实的断言逻辑错误重试次数再多也是浪费时间。所以这里要克制——不是所有失败都应该自动重跑重跑机制只应该用来过滤“偶发环境问题”。4.4 并行执行时的注意事项隔离性与缓存当一个项目的用例数量多到一定程度全局串行跑很快就会成为瓶颈。Cypress 支持并行执行但并行带来的隔离性问题比想象中多。首先是数据隔离不同分片同时操作同一套测试数据很容易互相影响。比如两个用例同时创建同名的用户后创建的可能把先创建的数据覆盖掉。我现在的处理原则是每个用例最好自己造自己用测试结束尽量清理产物。如果实在清理不了至少保证数据命名是随机且唯一的不让用例之间产生耦合。另一个注意点是依赖缓存Cypress 的二进制文件比较大流水线里最好单独缓存~/Library/Caches/Cypress这类目录否则每次跑都要重新下载浪费大量时间。最后说说我现在写测试的几个习惯把 Cypress 用熟之后我养成了几个固定动作。第一个是在新页面交付时先加一组冒烟用例只覆盖核心路径保证用户最常用的流程不会因为迭代被无声破坏第二个是每个用例都尽量写成一段可读的“业务故事”从准备数据到操作页面再到验证结果读用例代码的人不需要翻文档就能明白业务流程第三个是定期看失败用例的分类如果某类失败总是因为等待问题我会优先优化测试代码而不是去重跑。还有一个小技巧想分享给大家在afterEach里加一个轻量级的页面上报把每次用例结束时的当前页面 URL 打入日志。别小看这个信息当一堆用例并行失败时它能帮你快速判断是不是某个页面渲染异常导致了连锁故障而不是一条条去翻视频。写端到端测试本质上是在给项目的核心业务加一个安全网。Cypress 给了我们一套相当顺手的工具但真正让测试变稳的还是写测试的人有没有想清楚“要验证什么状态”和“如何稳定地等到这个状态”。希望这篇从选型到工程化的实践笔记能让你少踩几个我踩过的坑。