标签

Cypress

Cypress 是一个前端自动化测试工具,用于测试基于Web的应用程序。它能够测试运行在浏览器中的应用,并且适用于单元测试、集成测试和端到端(E2E)测试。Cypress 提供了一个丰富的API集,以及一个友好的交互式界面,让开发和测试人员能够轻松编写、运行和调试测试用例。

Cypress
查看更多相关内容
服务端5月27日 23:18
Cypress 的 beforeEach、before、afterEach 和 after 钩子有什么区别?## 核心区别 四个钩子的根本区别在于**执行频率**和**作用域**: - `beforeEach` / `afterEach`:每个 `it` 用例前后各执行一次,作用域为当前 `describe` 块内所有用例 - `before` / `after`:整个 `describe` 块开始前和结束后各执行一次,作用域为整个测试套件 | 钩子 | 执行时机 | 执行次数 | 典型用途 | |------|---------|---------|--------| | `beforeEach` | 每个 `it` 之前 | N 次(N = 用例数) | 重置状态、登录、访问页面 | | `afterEach` | 每个 `it` 之后 | N 次 | 清除 cookie、会话、快照 | | `before` | 所有 `it` 之前 | 1 次 | 种子数据、全局配置 | | `after` | 所有 `it` 之后 | 1 次 | 数据库清理、资源释放 | ## 执行顺序 嵌套 `describe` 时,钩子的执行顺序遵循"从外到内"原则: ```javascript describe("外层", () => { before(() => cy.log("outer before")); // 1 beforeEach(() => cy.log("outer beforeEach")); // 3, 7 describe("内层", () => { before(() => cy.log("inner before")); // 2 beforeEach(() => cy.log("inner beforeEach")); // 4, 8 it("测试A", () => cy.log("test A")); // 5 it("测试B", () => cy.log("test B")); // 9 afterEach(() => cy.log("inner afterEach")); // 6, 10 }); afterEach(() => cy.log("outer afterEach")); // 最后执行 }); ``` 输出:`outer before → inner before → (outer beforeEach → inner beforeEach → 测试A → inner afterEach → outer afterEach) × 2轮` ## 选择依据 - 需要每个用例都从干净状态开始 → `beforeEach`,不要用 `before` - 用例之间允许共享状态(如只读数据) → `before` 一次性初始化 - `afterEach` 适合清理当前用例产生的副作用(如 localStorage) - `after` 适合清理整个套件的资源(如测试数据库) **常见错误**:在 `before` 中登录,然后所有用例共享登录态。一旦某个用例意外注销,后续用例全部失败。正确做法是用 `beforeEach` 登录,保证每个用例的独立性。 ## 追问 **Q: `beforeEach` 中 `cy.visit()` 和 `cy.request()` 有什么区别?** `cy.visit()` 会加载完整页面并等待页面事件,较慢;`cy.request()` 只发 HTTP 请求不渲染,适合用 API 预设数据来加速测试。 **Q: 钩子中的断言失败会影响用例执行吗?** 会。`beforeEach` 中断言失败,该用例跳过;`afterEach` 中失败会标记用例为失败,但不影响下一个用例的执行。 **Q: 为什么 Cypress 官方更推荐 `beforeEach` 而非 `before`?** 因为 Cypress 的核心原则是测试隔离。`before` 共享状态容易导致用例间耦合,一个用例的副作用会污染后续用例。`beforeEach` 保证每个用例从相同初始状态运行,测试更稳定。
服务端5月27日 23:18
Cypress 自定义命令怎么用?Cypress 自定义命令(Custom Commands)是通过 `Cypress.Commands.add()` 在 `cypress/support/commands.js` 中注册的可复用测试函数,调用方式与 `cy.visit()` 等内置命令一致,核心目的是消除跨用例的重复代码。 ## 创建自定义命令 在 `cypress/support/commands.js` 中定义: ```javascript Cypress.Commands.add('login', (email, password) => { cy.visit('/login'); cy.get('[data-testid="email"]').type(email); cy.get('[data-testid="password"]').type(password); cy.get('[data-testid="submit"]').click(); }); ``` 测试中直接调用 `cy.login('user@example.com', 'password')`,无需每次重复编写登录步骤。 ## 三种命令类型 `Cypress.Commands.add()` 第二个参数可选 `prevSubject`,决定命令的调用方式: - **父命令**(默认):独立调用,如 `cy.login()` - **子命令**:必须链式接在前一个命令后,对获取到的元素操作 ```javascript Cypress.Commands.add('drag', { prevSubject: 'element' }, (subject, options) => { cy.wrap(subject) .trigger('mousedown', { button: 0 }) .trigger('mousemove', { clientX: options.x, clientY: options.y }) .trigger('mouseup'); }); // 使用:cy.get('.box').drag({ x: 100, y: 200 }) ``` - **双重命令**:`{ prevSubject: 'optional' }`,既可独立调用也可链式调用 ## 覆盖已有命令 用 `Cypress.Commands.overwrite()` 改写内置命令行为: ```javascript Cypress.Commands.overwrite('visit', (originalFn, url, options) => { return originalFn(url, { ...options, headers: { Authorization: 'Bearer ...' } }); }); ``` ## TypeScript 类型支持 在 `cypress/support/index.d.ts` 中声明类型,避免 TS 报错: ```typescript declare namespace Cypress { interface Chainable { login(email: string, password: string): Chainable<void>; drag(options: { x: number; y: number }): Chainable<void>; } } ``` ## 常见追问 **自定义命令和普通函数的区别?** 自定义命令运行在 Cypress 命令队列中,支持重试和超时机制;普通 JS 函数是同步执行,不具备这些能力。 **什么时候不该用自定义命令?** 仅在单个 spec 文件中复用的逻辑,写成普通函数更轻量;自定义命令适合跨文件、跨模块共享的场景。 **命令命名冲突怎么办?** 自定义命令会覆盖同名内置命令,建议用业务前缀(如 `cy.authLogin`)避免冲突。
服务端5月27日 23:17
Cypress 如何处理跨域问题?## 答案 Cypress 处理跨域问题有两种主要方式: 1. **禁用 Chrome Web 安全**:在 `cypress.config.js` 中设置 `chromeWebSecurity: false`,允许跨域导航和访问跨域 iframe。这是最简单的方式,但仅适用于 Chromium 内核浏览器。 2. **使用 `cy.origin()` 命令**:从 Cypress 9.6.0 起,可通过 `cy.origin()` 在不同域上执行操作,Cypress 会为新源创建 iframe 并通过 `postMessage` 通信。这是官方推荐的跨域测试方案。 ```javascript // 方式一:禁用 Chrome Web 安全 // cypress.config.js module.exports = { e2e: { chromeWebSecurity: false } }; // 方式二:cy.origin() cy.origin("https://example.com", () => { cy.visit("/login"); cy.get("input[name=email]").type("test@example.com"); cy.get("button[type=submit]").click(); }); ``` ## 追问:chromeWebSecurity: false 有什么局限? - 仅对 Chromium 内核浏览器有效,Firefox 等浏览器不支持此选项 - 不会绕过 `cy.visit()` 的超域限制,即不能在同一个测试中 `cy.visit()` 不同超域的 URL - 生产环境不存在此开关,测试可能掩盖真实的跨域问题 ## 追问:cy.origin() 的注意事项? - 回调函数内无法直接引用外部作用域的变量,需通过第二个参数传入: ```javascript const username = "test@example.com"; cy.origin("https://example.com", { args: { username } }, ({ username }) => { cy.get("input[name=email]").type(username); }); ``` - 回调内不能使用 `cy.session()`、自定义命令等部分 API - 每次调用 `cy.origin()` 会创建新的 iframe 上下文,有性能开销 ## 追问:还有其他方案吗? 可通过服务器端反向代理(如 nginx)将不同域的 API 映射到同源路径下,从根本上消除跨域。这种方式不依赖 Cypress 配置,但需要额外的基础设施支持。 ```nginx # nginx 反向代理示例 location /external-api/ { proxy_pass https://api.example.com/; } ```
服务端5月27日 23:04
Cypress 的测试钩子(before、after、beforeEach、afterEach)如何使用?Cypress 基于 Mocha 框架,提供了四个测试钩子来控制测试生命周期:`before`、`after`、`beforeEach` 和 `afterEach`。它们让你可以在测试执行前后插入初始化和清理逻辑,避免在每个测试用例中重复编写相同的准备代码。 理解它们的区别很简单——`before`/`after` 在整个 `describe` 块中只跑一次,`beforeEach`/`afterEach` 在每个 `it` 用例前后各跑一次。 ## 四个钩子的执行顺序 先看一段代码,搞清楚它们到底谁先谁后: ```javascript describe('钩子执行顺序', () => { before(() => cy.log('1. before')); beforeEach(() => cy.log('2. beforeEach')); afterEach(() => cy.log('4. afterEach')); after(() => cy.log('5. after')); it('测试用例 A', () => cy.log('3. it A')); it('测试用例 B', () => cy.log('3. it B')); }); ``` 执行日志依次为: ``` 1. before ← 整个套件开始前,执行一次 2. beforeEach ← 用例 A 前 3. it A 4. afterEach ← 用例 A 后 2. beforeEach ← 用例 B 前 3. it B 4. afterEach ← 用例 B 后 5. after ← 整个套件结束后,执行一次 ``` 记住这条链路:**before → (beforeEach → it → afterEach) × N → after**,所有关于钩子的问题都能用这条链路解释。 ## before:整个套件只执行一次的前置操作 `before` 适合做那些"只需要做一次"的初始化工作。 **典型场景**: - 访问被测页面 `cy.visit('/login')` - 用 `cy.session()` 建立全局登录态 - 通过 `cy.request()` 预设后端数据 ```javascript describe('商品列表页', () => { before(() => { // 一次性访问目标页面 cy.visit('/products'); }); it('页面标题正确', () => { cy.get('h1').should('contain', '商品列表'); }); it('列表不为空', () => { cy.get('.product-item').should('have.length.gt', 0); }); }); ``` **关键注意点**:`before` 中通过 `cy.visit()` 访问页面后,Cypress 会保持该页面状态,后续用例不需要再次访问。但如果某个用例导航到了别的页面,就需要在 `beforeEach` 中重新访问。 ## after:整个套件只执行一次的收尾操作 `after` 在所有测试用例和所有 `afterEach` 执行完之后运行,适合做全局清理。 ```javascript describe('用户管理接口测试', () => { before(() => { // 创建测试用户 cy.request('POST', '/api/users', { name: 'test_user', email: 'test@example.com' }); }); after(() => { // 清理:删除测试过程中创建的用户 cy.request('DELETE', '/api/users/test@example.com'); }); it('用户可以被查询到', () => { cy.request('/api/users/test@example.com') .its('status') .should('eq', 200); }); }); ``` **注意**:Cypress 官方建议谨慎依赖 `after` 做状态清理。如果测试中途失败,`after` 中的清理逻辑可能不会执行,残留数据会影响下次运行。更稳妥的做法是在 `before` 中先清理再初始化,确保每次测试都从干净状态开始。 ## beforeEach:每个用例前的状态重置 `beforeEach` 是使用频率最高的钩子。它保证每个 `it` 用例运行前都有一个干净、一致的初始状态,是测试隔离的核心手段。 ```javascript describe('登录表单', () => { beforeEach(() => { // 每个用例前都重新访问登录页 cy.visit('/login'); }); it('空字段提交时显示错误提示', () => { cy.get('button[type="submit"]').click(); cy.get('.error-msg').should('be.visible'); }); it('输入正确凭据后跳转到首页', () => { cy.get('#username').type('admin'); cy.get('#password').type('secret'); cy.get('button[type="submit"]').click(); cy.url().should('include', '/dashboard'); }); it('密码错误时显示错误提示', () => { cy.get('#username').type('admin'); cy.get('#password').type('wrong'); cy.get('button[type="submit"]').click(); cy.get('.error-msg').should('contain', '密码不正确'); }); }); ``` `beforeEach` 最大的价值是**测试隔离**——无论前一个用例做了什么操作(比如输入了错误密码),下一个用例都会从全新的登录页开始,互不干扰。 ## afterEach:每个用例后的清理 `afterEach` 在每个 `it` 用例结束后执行,适合做用例级别的清理或结果校验。 ```javascript describe('购物车操作', () => { beforeEach(() => { cy.visit('/cart'); }); afterEach(() => { // 每个用例后清空 localStorage,防止状态残留 cy.clearLocalStorage(); // 每个用例后截图,方便排查失败原因 cy.screenshot(); }); it('添加商品后数量更新', () => { cy.get('.add-btn').first().click(); cy.get('#cart-count').should('eq', '1'); }); it('删除商品后列表清空', () => { cy.get('.add-btn').first().click(); cy.get('.remove-btn').first().click(); cy.get('.cart-item').should('not.exist'); }); }); ``` ## before vs beforeEach:什么时候用哪个? 这是新手最常混淆的问题,核心区别就一句话:**`before` 执行一次,`beforeEach` 执行 N 次(N = 测试用例数量)**。 | 维度 | before | beforeEach | |------|--------|------------| | 执行次数 | 1 次 | 每个用例前各 1 次 | | 适合做什么 | 访问页面、建立全局会话 | 重置表单、清空输入、还原状态 | | 状态共享 | 用例间共享 `before` 的状态 | 每个用例独立,不受前序用例影响 | | 风险 | 某个用例修改了共享状态,后续用例可能受影响 | 更安全,但重复执行会有性能开销 | **选择建议**:如果初始化操作是"只读"的(比如访问页面、读取配置),用 `before` 就够了。如果涉及"写操作"或者需要确保每个用例的状态独立,用 `beforeEach`。 ## 嵌套 describe 中的钩子继承 当 `describe` 嵌套时,内层会继承外层的所有钩子,并且外层钩子先于内层钩子执行: ```javascript describe('外层套件', () => { before(() => cy.log('外层 before')); beforeEach(() => cy.log('外层 beforeEach')); describe('内层套件', () => { before(() => cy.log('内层 before')); beforeEach(() => cy.log('内层 beforeEach')); it('用例 1', () => cy.log('用例 1 执行')); }); }); ``` 执行顺序: ``` 外层 before → 内层 before → 外层 beforeEach → 内层 beforeEach → 用例 1 ``` 注意:`before` 在**嵌套场景下的行为**容易踩坑——内层的 `before` 并非"在内层用例前"执行,而是在整个嵌套套件的 `before` 阶段一起执行。如果内层有多个用例,内层 `before` 也只执行一次,不是每个内层用例前都执行。 ## 常见问题与踩坑 ### 1. before 中的状态泄漏 Cypress 中 `before` 里用 `this` 赋值的变量,后续用例可以通过 `this` 访问。但如果你在某个用例中修改了 `this` 上的值,这个修改会持续影响后面的用例: ```javascript describe('状态泄漏示例', function() { before(function() { this.count = 0; }); it('用例 A', function() { this.count = 10; // 修改了 this.count expect(this.count).to.eq(10); }); it('用例 B', function() { // this.count 已经被用例 A 修改为 10,不是初始的 0 expect(this.count).to.eq(10); // 这会通过,但不一定是你预期的 }); }); ``` 解决方案:用 `beforeEach` 重置状态,或者用闭包变量(`const`/`let`)代替 `this`,避免状态在用例间泄漏。 ### 2. 钩子中混用 async/await Cypress 的 `cy` 命令不是 Promise,不能直接用 `await`。在钩子中如果需要等待 `cy` 命令完成,直接链式调用即可,不要加 `async`: ```javascript // ❌ 错误:cy.visit 不会被 await 正确等待 before(async () => { await cy.visit('/login'); }); // ✅ 正确:直接链式调用 before(() => { cy.visit('/login'); }); ``` ### 3. after/afterEach 不执行的边界情况 当测试中途失败或被手动中断时,`after` 和 `afterEach` 可能不会执行。如果你的清理逻辑很关键(比如删除测试数据),不要只放在 `after` 中,而是在 `before` 中先做一次清理: ```javascript describe('健壮的清理策略', () => { before(() => { // 先清理上次可能残留的数据,再初始化本次数据 cy.request('DELETE', '/api/test-data'); cy.request('POST', '/api/test-data', { name: 'test' }); }); after(() => { // 正常结束时也清理 cy.request('DELETE', '/api/test-data'); }); }); ``` ## 四个钩子的完整协作示例 把四个钩子放在一起,看看它们在实际项目中如何配合: ```javascript describe('电商下单流程', () => { before(() => { // 全局初始化:创建测试商品 cy.request('POST', '/api/products', { id: 'prod_001', name: '测试商品', price: 99.9 }); // 访问商品页 cy.visit('/products/prod_001'); }); beforeEach(() => { // 每个用例前:确保在正确的页面上 cy.visit('/products/prod_001'); // 清空购物车 cy.request('DELETE', '/api/cart'); }); afterEach(() => { // 每个用例后:截图留档 cy.screenshot(); }); after(() => { // 全局清理:删除测试商品 cy.request('DELETE', '/api/products/prod_001'); }); it('添加商品到购物车', () => { cy.get('.add-to-cart').click(); cy.get('#cart-count').should('contain', '1'); }); it('修改商品数量', () => { cy.get('.add-to-cart').click(); cy.get('.quantity-input').clear().type('3'); cy.get('.cart-total').should('contain', '299.7'); }); it('删除购物车商品', () => { cy.get('.add-to-cart').click(); cy.get('.remove-item').click(); cy.get('#cart-count').should('contain', '0'); }); }); ``` 掌握这四个钩子的执行时机和使用场景,是写出干净、可靠、可维护的 Cypress 测试的基础。核心原则就两条:需要共享的只做一次用 `before`/`after`,需要隔离的每次都做用 `beforeEach`/`afterEach`。
服务端5月27日 23:03
Cypress 中怎么处理认证和授权?从 cy.session 到多角色测试的实战方案在端到端测试中,认证和授权是最容易出问题的环节。登录流程写不好,测试就跑不通;权限验证不充分,线上就出漏洞。Cypress 提供了 `cy.session()`、`cy.request()`、`cy.intercept()` 等一整套工具来处理这些场景,但很多项目还在用最原始的方式——每个测试用例都跑一遍登录 UI,既慢又不稳定。 这篇文章从实际项目出发,讲清楚 Cypress 中处理认证和授权的几种典型模式,包括会话管理、程序化登录、多角色切换、JWT/OAuth 集成,以及常见坑点。 ## 用 cy.session() 管理登录会话 `cy.session()` 是 Cypress 处理认证的核心命令。它的作用很简单:第一次执行真正的登录流程,然后把 cookies、localStorage、sessionStorage 全部缓存起来,后续测试直接恢复,不再重复登录。 ### 基本用法 ```javascript // cypress/support/commands.js Cypress.Commands.add('login', (username, password) => { cy.session([username, password], () => { cy.visit('/login') cy.get('[data-testid=username]').type(username) cy.get('[data-testid=password]').type(password) cy.get('[data-testid=submit]').click() cy.url().should('contain', '/dashboard') }) }) ``` 在测试中使用: ```javascript describe('受保护页面', () => { beforeEach(() => { cy.login('testuser', 'password123') }) it('应该能看到仪表盘', () => { cy.visit('/dashboard') cy.get('[data-testid=welcome]').should('contain', 'Welcome') }) }) ``` 注意一点:`cy.session()` 执行后,页面会被重置为 `about:blank`,所以每个测试用例里必须显式调用 `cy.visit()` 去访问目标页面。 ### 用 validate 检查会话是否还有效 如果 token 有过期时间,可以给 `cy.session()` 加一个 `validate` 回调。Cypress 每次恢复会话前都会先跑这个验证,失败了就重新登录: ```javascript Cypress.Commands.add('loginWithValidation', (username, password) => { cy.session([username, password], () => { cy.visit('/login') cy.get('[data-testid=username]').type(username) cy.get('[data-testid=password]').type(password) cy.get('[data-testid=submit]').click() cy.url().should('contain', '/dashboard') }, { validate() { cy.request('/api/auth/me').its('status').should('eq', 200) } }) }) ``` 这样即使 token 过期了,测试也不会因为 401 而挂掉。 ## 程序化登录:跳过 UI 直接到 API 拿 token UI 登录慢,而且容易因为页面改动而断裂。对于测试来说,更稳的做法是直接调 API 拿 token,然后注入到浏览器中。 ### 基于 JWT 的程序化登录 ```javascript Cypress.Commands.add('loginByAPI', () => { cy.session('api-user', () => { cy.request('POST', '/api/auth/login', { username: Cypress.env('TEST_USERNAME'), password: Cypress.env('TEST_PASSWORD') }).then(({ body }) => { // 把 token 存到 localStorage window.localStorage.setItem('auth_token', body.token) }) }) }) ``` 环境变量放在 `cypress.env.json` 里: ```json { "TEST_USERNAME": "testuser@example.com", "TEST_PASSWORD": "securepassword" } ``` 这种方式比 UI 登录快好几倍,而且不受页面样式变化影响。 ### 处理 OAuth / 第三方登录 Cypress 官方不建议在测试中去操作第三方登录页面(比如 Google、GitHub 的 OAuth 页面),因为那些页面不受你控制,随时可能改版导致测试失败。正确的做法是程序化获取 token: ```javascript // 以 Auth0 为例 Cypress.Commands.add('loginByAuth0', () => { cy.session('auth0-user', () => { cy.request({ method: 'POST', url: `https://${Cypress.env('AUTH0_DOMAIN')}/oauth/token`, body: { grant_type: 'password', client_id: Cypress.env('AUTH0_CLIENT_ID'), client_secret: Cypress.env('AUTH0_CLIENT_SECRET'), username: Cypress.env('AUTH0_USERNAME'), password: Cypress.env('AUTH0_PASSWORD'), audience: Cypress.env('AUTH0_AUDIENCE'), scope: 'openid profile email' } }).then(({ body }) => { // Auth0 返回 access_token 和 id_token window.localStorage.setItem('auth0_token', body.access_token) }) }) }) ``` 需要在 Auth0 后台开启 Password Grant 类型,并创建专门的测试用户。 ## 授权测试:验证不同角色的访问权限 认证解决的是"你是谁"的问题,授权解决的是"你能干什么"的问题。在测试中,最关键的是确保不同角色看到的内容和能执行的操作是正确的。 ### 多角色会话管理 用 `cy.session()` 的不同 ID 来管理多个角色: ```javascript // 管理员登录 Cypress.Commands.add('loginAsAdmin', () => { cy.session('admin-user', () => { cy.request('POST', '/api/auth/login', { username: Cypress.env('ADMIN_USERNAME'), password: Cypress.env('ADMIN_PASSWORD') }).then(({ body }) => { window.localStorage.setItem('auth_token', body.token) }) }) }) // 普通用户登录 Cypress.Commands.add('loginAsUser', () => { cy.session('regular-user', () => { cy.request('POST', '/api/auth/login', { username: Cypress.env('USER_USERNAME'), password: Cypress.env('USER_PASSWORD') }).then(({ body }) => { window.localStorage.setItem('auth_token', body.token) }) }) }) ``` ### 用 cy.intercept() 验证 API 权限 ```javascript describe('管理员权限', () => { beforeEach(() => { cy.loginAsAdmin() cy.intercept('GET', '/api/admin/users').as('getUsers') }) it('管理员应该能访问用户列表', () => { cy.visit('/admin/users') cy.wait('@getUsers').its('response.statusCode').should('eq', 200) cy.get('[data-testid=user-list]').should('be.visible') }) }) describe('普通用户权限', () => { beforeEach(() => { cy.loginAsUser() }) it('普通用户不应该看到管理后台', () => { cy.request({ url: '/api/admin/users', failOnStatusCode: false }).its('status').should('eq', 403) }) }) ``` 这里有个细节:`cy.request()` 默认在收到 4xx 状态码时会抛错,加上 `failOnStatusCode: false` 才能正常断言 403。 ## Cookies 和 Storage 的操作与清理 认证状态通常存储在 cookies 或 localStorage 中,Cypress 提供了专门的 API 来操作它们。 ### 读取和验证 ```javascript // 验证认证 cookie 存在 cy.getCookie('session_id').should('exist') // 检查 cookie 值和安全属性 cy.getCookie('auth_token').then((cookie) => { expect(cookie.value).to.include('Bearer') expect(cookie.httpOnly).to.be.true // 确保 HttpOnly 标记 expect(cookie.secure).to.be.true // 确保 Secure 标记 }) // 操作 localStorage cy.window().then((win) => { const token = win.localStorage.getItem('auth_token') expect(token).to.not.be.null }) ``` ### 测试隔离:每个用例前清理状态 ```javascript beforeEach(() => { cy.clearCookies() cy.clearLocalStorage() }) ``` 这一步很重要。如果不清理,上一个测试的登录状态可能"泄漏"到下一个测试,导致本应失败的测试意外通过。 ## 常见坑和解决方案 ### 坑 1:cy.session() 后忘记 cy.visit() `cy.session()` 执行后会重置页面到 `about:blank`。如果你直接在后面断言页面元素,一定会失败。必须在 `cy.session()` 之后、断言之前调用 `cy.visit()`。 ```javascript // 错误写法 beforeEach(() => { cy.login('user', 'pass') // 页面是 about:blank,下面的断言会失败 cy.get('h1').should('contain', 'Dashboard') }) // 正确写法 beforeEach(() => { cy.login('user', 'pass') cy.visit('/dashboard') cy.get('h1').should('contain', 'Dashboard') }) ``` ### 坑 2:硬编码凭据 不要把用户名密码直接写在测试代码里。用 `cypress.env.json`(已被 gitignore)或 CI 环境变量来管理: ```javascript // 错误 cy.get('input[name=username]').type('admin') cy.get('input[name=password]').type('123456') // 正确 cy.get('input[name=username]').type(Cypress.env('ADMIN_USERNAME')) cy.get('input[name=password]').type(Cypress.env('ADMIN_PASSWORD')) ``` ### 坑 3:跨域认证测试 当登录页面和应用页面不在同一个域下时(比如 Auth0 登录在 auth0.com,应用在 example.com),需要用 `cy.origin()` 来处理跨域操作: ```javascript Cypress.Commands.add('loginWithSSO', () => { cy.session('sso-user', () => { cy.visit('/login') cy.get('[data-testid=sso-button]').click() cy.origin('https://auth.example.com', () => { cy.get('input[name=email]').type(Cypress.env('SSO_EMAIL')) cy.get('input[name=password]').type(Cypress.env('SSO_PASSWORD')) cy.get('button[type=submit]').click() }) cy.url().should('contain', '/dashboard') }) }) ``` ### 坑 4:测试间状态泄漏 如果某个测试修改了用户角色或权限,后续测试可能因为这个修改而行为异常。解决办法是确保每个测试有独立的初始状态: ```javascript // 用 cy.session() 自动隔离 // 用 cy.database() 或 cy.task() 重置测试数据 before(() => { cy.task('db:seed') }) ``` ## 完整示例:一个认证测试套件 把上面的内容整合起来,一个实际项目中可用的认证测试套件大概长这样: ```javascript // cypress/support/commands.js Cypress.Commands.add('login', (role = 'user') => { const accounts = { admin: { username: Cypress.env('ADMIN_USER'), password: Cypress.env('ADMIN_PASS') }, user: { username: Cypress.env('TEST_USER'), password: Cypress.env('TEST_PASS') }, guest: { username: Cypress.env('GUEST_USER'), password: Cypress.env('GUEST_PASS') } } const account = accounts[role] cy.session(`user-${role}`, () => { cy.request('POST', '/api/auth/login', account).then(({ body }) => { window.localStorage.setItem('auth_token', body.token) }) }, { validate() { cy.request({ url: '/api/auth/me', failOnStatusCode: false }) .its('status').should('eq', 200) } }) }) ``` ```javascript // cypress/e2e/auth.cy.js describe('认证流程', () => { it('未登录用户应该被重定向到登录页', () => { cy.visit('/dashboard') cy.url().should('include', '/login') }) it('登录成功后应该跳转到仪表盘', () => { cy.visit('/login') cy.get('[data-testid=username]').type(Cypress.env('TEST_USER')) cy.get('[data-testid=password]').type(Cypress.env('TEST_PASS')) cy.get('[data-testid=submit]').click() cy.url().should('include', '/dashboard') }) it('错误的密码应该显示错误提示', () => { cy.visit('/login') cy.get('[data-testid=username]').type(Cypress.env('TEST_USER')) cy.get('[data-testid=password]').type('wrongpassword') cy.get('[data-testid=submit]').click() cy.get('[data-testid=error-message]').should('be.visible') }) }) describe('权限控制', () => { it('管理员可以访问设置页', () => { cy.login('admin') cy.visit('/settings') cy.get('[data-testid=settings-panel]').should('be.visible') }) it('普通用户不能访问设置页', () => { cy.login('user') cy.request({ url: '/api/admin/settings', failOnStatusCode: false }) .its('status').should('eq', 403) }) }) ``` 这套方案的核心思路是:登录流程只测一次 UI,其余全部走 API;权限测试通过角色切换覆盖不同场景;`cy.session()` + `validate` 保证会话有效且不重复登录。 关于认证测试的更多细节,可以参考 [Cypress Authentication 官方指南](https://docs.cypress.io/guides/testing-techniques/authentication) 和 [cy.session() API 文档](https://docs.cypress.io/api/commands/session)。
服务端5月27日 23:02
Cypress 如何管理环境变量和配置?Cypress 测试要在开发、测试、预发布、生产等多个环境中跑,每个环境的 API 地址、账号密码、超时阈值都不一样。如果把这些值硬编码在测试代码里,换个环境就全崩了——这正是环境变量和配置管理要解决的问题。 Cypress 提供了一套分层的环境变量体系,优先级从高到低依次是:命令行 `--env` 参数 > `CYPRESS_` 前缀系统变量 > `cypress.env.json` 文件 > `cypress.config.js` 中的 `env` 字段。理解这套优先级,才能知道变量到底从哪来、被谁覆盖了。下面逐层拆解。 ## cypress.config.js 中定义默认环境变量 `cypress.config.js` 是 Cypress 的主配置入口,在 `env` 字段中可以声明所有环境变量的默认值: ```javascript const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:3000', env: { API_BASE_URL: 'https://dev.api.example.com', TIMEOUT_MS: 10000, }, }, }); ``` 这种方式最简单,适合放不敏感的默认值。但有两个限制:第一,所有环境变量都暴露在代码仓库里,敏感信息不能放这里;第二,每次改值都要改文件提交,不适合频繁切换环境。 ## cypress.env.json —— 独立的环境变量文件 Cypress 会自动加载项目根目录下的 `cypress.env.json`,它的值会覆盖 `cypress.config.js` 中同名的 `env` 变量: ```json { "API_BASE_URL": "https://staging.api.example.com", "ADMIN_USERNAME": "staging_admin" } ``` **重要**:`cypress.env.json` 必须加入 `.gitignore`,防止敏感信息提交到仓库。 这种方式的好处是:本地开发时每个测试人员可以维护自己的 `cypress.env.json`,互不干扰,而仓库里只保留 `cypress.config.js` 的默认值。CI 环境中则通过命令行参数或系统变量覆盖,不需要这个文件。 ## CYPRESS_ 前缀的系统环境变量 任何以 `CYPRESS_` 或 `cypress_` 开头的系统环境变量,Cypress 都会自动识别并注入。变量名会去掉前缀并转为大写: ```bash # 设置系统环境变量 export CYPRESS_API_BASE_URL=https://prod.api.example.com export CYPRESS_ADMIN_PASSWORD=secret123 # 运行测试 npx cypress run ``` 在测试中通过 `Cypress.env('API_BASE_URL')` 就能拿到值。这个机制特别适合 CI 环境——在 CI 平台的安全变量配置里设置 `CYPRESS_` 前缀变量,测试运行时自动生效,不需要额外代码。 ## 命令行 --env 参数:优先级最高的覆盖方式 `--env` 参数的优先级最高,会覆盖上面所有来源的同名变量: ```bash # 传递单个变量 npx cypress run --env API_BASE_URL=https://prod.api.example.com # 传递多个变量,用逗号分隔 npx cypress run --env API_BASE_URL=https://prod.api.example.com,ADMIN_PASSWORD=ci_secret ``` CI 管道中经常这样用:构建脚本根据目标环境动态拼接 `--env` 参数,实现一套代码跑多套环境。 ## 在测试代码中读取和临时修改环境变量 ### 读取:Cypress.env() ```javascript it('验证登录接口返回 200', () => { const apiUrl = Cypress.env('API_BASE_URL'); const username = Cypress.env('ADMIN_USERNAME'); cy.request({ url: `${apiUrl}/login`, method: 'POST', body: { username, password: Cypress.env('ADMIN_PASSWORD') }, }).then((response) => { expect(response.status).to.eq(200); }); }); ``` 不带参数调用 `Cypress.env()` 会返回所有环境变量的对象,方便一次性取多个值。 ### 临时修改:运行时覆盖 ```javascript describe('生产环境模拟', () => { let originalUrl; before(() => { originalUrl = Cypress.env('API_BASE_URL'); Cypress.env('API_BASE_URL', 'https://prod.api.example.com'); }); after(() => { // 恢复原始值,避免影响其他测试 Cypress.env('API_BASE_URL', originalUrl); }); it('生产环境接口响应时间应小于 2s', () => { cy.request(Cypress.env('API_BASE_URL') + '/health').then((res) => { expect(res.duration).to.be.lessThan(2000); }); }); }); ``` `Cypress.env(key, value)` 修改的值只在当前测试运行期间生效,测试结束后自动恢复。但同一 spec 文件中的后续测试仍会读到修改后的值,所以最好在 `after` 或 `afterEach` 中手动恢复。 ## 多环境配置的实战方案 项目里通常有三个以上的环境,靠一个 `cypress.config.js` 不够用。常见的做法是拆分配置文件: ``` cypress/ config/ development.json staging.json production.json cypress.config.js ``` 各环境配置文件内容示例: ```json { "baseUrl": "https://staging.example.com", "env": { "API_BASE_URL": "https://staging.api.example.com", "TIMEOUT_MS": 15000 } } ``` 然后在 `package.json` 中配置快捷命令: ```json { "scripts": { "cy:open:dev": "cypress open --config-file cypress/config/development.json", "cy:open:staging": "cypress open --config-file cypress/config/staging.json", "cy:run:prod": "cypress run --config-file cypress/config/production.json" } } ``` 这样执行 `npm run cy:run:prod` 就自动加载生产环境配置,不需要每次手动传参。 ## dotenv 集成:在配置文件中加载 .env 如果团队已经在用 `.env` 管理项目的环境变量,Cypress 可以直接复用: ```bash npm install dotenv --save-dev ``` ```javascript // cypress.config.js const { defineConfig } = require('cypress'); require('dotenv').config(); module.exports = defineConfig({ e2e: { baseUrl: process.env.BASE_URL || 'http://localhost:3000', env: { API_SECRET: process.env.API_SECRET, TEST_ENV: process.env.TEST_ENV || 'development', }, }, }); ``` `.env` 文件同样要加入 `.gitignore`。这个方案的优势是:项目其他部分(如 Next.js、Node 服务)也读 `.env`,一套文件多处复用,维护成本低。 ## CI/CD 中的环境变量管理 不同 CI 平台的注入方式略有差异,但核心思路一致:把敏感值放在平台的 Secrets 配置中,非敏感值放在环境变量中。 ### GitHub Actions 示例 ```yaml jobs: e2e-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install dependencies run: npm ci - name: Run Cypress env: CYPRESS_API_SECRET: ${{ secrets.API_SECRET }} CYPRESS_TEST_ENV: production run: npx cypress run ``` ### GitLab CI 示例 ```yaml e2e-test: image: cypress/browsers:latest script: - npm ci - npx cypress run --env API_BASE_URL=$STAGING_API_URL variables: STAGING_API_URL: "https://staging.api.example.com" ``` 关键原则:**永远不要在 YAML 文件里直接写密码和密钥**,一律用平台 Secrets 功能。 ## 环境变量优先级完整对照表 | 优先级 | 来源 | 示例 | 适用场景 | |--------|------|------|----------| | 1(最高) | `--env` 命令行参数 | `--env API_URL=prod` | CI 动态覆盖 | | 2 | `CYPRESS_` 前缀系统变量 | `CYPRESS_API_URL=prod` | CI/本地临时设置 | | 3 | `cypress.env.json` | `{"API_URL":"staging"}` | 本地开发(不入库) | | 4(最低) | `cypress.config.js` 的 `env` | `env: { API_URL: 'dev' }` | 默认值 | 优先级高的会覆盖低的同名变量。如果同一个变量在四处都设置了,最终取优先级最高的那个值。 ## 常见问题排查 **变量读不到,返回 undefined** 检查变量名是否一致。`CYPRESS_` 前缀的变量名会去掉前缀,比如系统变量 `CYPRESS_API_KEY` 在测试中用 `Cypress.env('API_KEY')` 读取。注意大小写:Cypress 内部会将变量名转为大写。 **cypress.env.json 没生效** 确认文件在项目根目录(与 `cypress.config.js` 同级),且文件名拼写正确。另外检查 JSON 格式是否合法——多一个逗号都会导致静默失败。 **CI 中环境变量覆盖不了本地值** 可能是变量名不匹配。本地 `cypress.env.json` 写的是 `api_base_url`,CI 用 `CYPRESS_API_BASE_URL` 注入,两者大小写不同,Cypress 不会自动合并。建议统一用大写命名。 **dotenv 加载失败** `require('dotenv').config()` 要放在 `cypress.config.js` 的最顶部,且 `.env` 文件路径要正确。如果 `.env` 不在项目根目录,需要指定路径:`require('dotenv').config({ path: '../.env' })`。
服务端5月27日 23:02
Cypress 可访问性测试怎么做?cypress-axe 集成与 WCAG 合规实战## Cypress 可访问性测试怎么做?cypress-axe 集成与 WCAG 合规实战 可访问性测试(Accessibility Testing,简称 a11y)验证 Web 应用能否被残障人士正常使用。Cypress 本身不内置可访问性检查能力,但通过集成 axe-core 引擎,可以用 `cy.checkA11y()` 一行命令扫描页面上违反 WCAG 标准的元素。 这篇文章覆盖从插件安装、测试编写到 CI 集成的完整流程,并补充 Cypress Accessibility Cloud 和 wick-a11y 两个新方案。 ## cypress-axe 插件怎么安装和配置 cypress-axe 是 Cypress 社区使用最广的可访问性插件,封装了 Deque 公司的 axe-core 规则引擎。安装分三步: 第一步,装包: ```bash npm install cypress-axe --save-dev ``` 第二步,在 `cypress/support/e2e.js` 中引入: ```javascript import 'cypress-axe'; ``` 第三步,在每个测试前注入 axe-core 到页面。cypress-axe 提供了 `cy.injectAxe()` 命令,通常放在 `beforeEach` 里: ```javascript beforeEach(() => { cy.visit('/login'); cy.injectAxe(); }); ``` `injectAxe()` 的作用是把 axe-core 的脚本注入到当前页面的 window 对象上。不调用它,`cy.checkA11y()` 会报错。 有几点需要注意: - cypress-axe 不是 Cypress 官方包,它依赖 axe-core,两者版本要兼容。查看 cypress-axe 的 changelog 确认支持的 axe-core 版本 - 如果项目用 TypeScript,可能需要 `cypress-axe` 的类型声明:`npm install -D @types/cypress-axe` - `injectAxe()` 必须在页面加载之后调用,否则找不到 document 对象 ## cy.checkA11y() 的基本用法和参数 ### 全页面扫描 最简单的用法,检查整个页面: ```javascript it('登录页没有可访问性问题', () => { cy.visit('/login'); cy.injectAxe(); cy.checkA11y(); }); ``` 测试失败时,Cypress 命令行会输出每个违规项的详细信息:规则 ID、影响级别(critical / serious / moderate / minor)、违规元素选择器、修复建议。 ### 指定扫描范围 只检查某个容器内的元素: ```javascript cy.checkA11y('.main-content'); // 或者用 Cypress 链式查找 cy.get('form').checkA11y(); ``` ### 自定义规则和运行参数 `checkA11y` 的第二个参数是 axe 的配置对象: ```javascript cy.checkA11y(null, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] // 只检查 WCAG 2.x A 和 AA 级别 }, rules: { 'color-contrast': { enabled: false }, // 暂时跳过颜色对比度 'region': { enabled: true } // 强制检查地标区域 } }); ``` ### 排除特定元素 第三方组件或广告区域可能无法修改,可以排除: ```javascript cy.checkA11y({ exclude: ['.ad-banner', '#third-party-widget'] }); ``` ### 自定义违规回调 不希望测试直接失败,而是收集违规信息做进一步处理: ```javascript cy.checkA11y(null, null, (violations) => { violations.forEach((v) => { cy.log(`${v.id}: ${v.nodes.length} 个元素违规`); }); }, true); // 第四个参数 skipFailures = true,不导致测试失败 ``` ## 实际项目中的测试策略 ### 按页面或功能模块编写测试 不建议把所有可访问性检查塞进一个巨大的测试文件。按页面拆分更清晰: ```javascript // cypress/e2e/accessibility/login.spec.js describe('登录页可访问性', () => { beforeEach(() => { cy.visit('/login'); cy.injectAxe(); }); it('初始状态符合 WCAG 2.1 AA', () => { cy.checkA11y(null, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } }); }); it('表单报错时的提示可被屏幕阅读器识别', () => { cy.get('button[type="submit"]').click(); // 等待错误提示出现 cy.get('.error-message').should('be.visible'); cy.checkA11y(); }); }); ``` ### 键盘导航和焦点管理 axe-core 无法检测所有键盘交互问题。需要手动编写测试来补充: ```javascript it('可以用 Tab 键在表单元素间导航', () => { cy.get('input[name="email"]').focus(); cy.focused().tab(); cy.focused().should('have.attr', 'name', 'password'); cy.focused().tab(); cy.focused().should('have.attr', 'type', 'submit'); }); it('焦点不会跳到隐藏的模态框', () => { cy.get('[role="dialog"]').should('not.be.visible'); cy.get('body').tab({ shift: true }); cy.focused().should('not.have.attr', 'role', 'dialog'); }); ``` ### ARIA 属性断言 对关键 ARIA 属性做显式断言,比依赖自动扫描更可靠: ```javascript it('导航菜单的 ARIA 属性正确', () => { cy.get('nav').should('have.attr', 'role', 'navigation'); cy.get('nav').should('have.attr', 'aria-label'); }); it('按钮有可访问名称', () => { cy.get('button.submit') .should('have.attr', 'aria-label') .or('have.text'); // 至少有 aria-label 或文本内容 }); ``` ## cypress-axe 之外的选择 ### wick-a11y wick-a11y 是 cypress-axe 的替代方案,提供了更清晰的 HTML 报告和截图标注: ```bash npm install -D wick-a11y ``` ```javascript // cypress/support/e2e.js import 'wick-a11y'; ``` 使用 `cy.checkAccessibility()` 代替 `cy.checkA11y()`: ```javascript it('首页可访问性检查', () => { cy.visit('/'); cy.checkAccessibility(); }); ``` wick-a11y 的优势在于测试失败时直接在 Cypress 截图上标注违规元素位置,比纯文本日志更容易定位问题。 ### Cypress Accessibility Cloud 2025 年 Cypress 推出了 Cypress Accessibility 平台,集成在 Cypress Cloud 中。它不需要额外安装插件,而是基于已有的测试录制自动生成可访问性报告。 使用方式很简单:只要测试运行时开启了云录制,Cypress Cloud 会自动分析页面快照,标记可访问性问题。这对不想维护额外测试代码的团队是个低门槛选项。 不过它目前只覆盖部分 WCAG 规则,深度检查仍然需要 cypress-axe 或 wick-a11y。 ## CI 集成和报告 ### GitHub Actions 配置 ```yaml name: E2E with A11y on: [push] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: cypress-io/github-action@v6 with: spec: cypress/e2e/accessibility/**/*.spec.js ``` ### 生成 HTML 报告 用 `cypress-axe-reporter` 或 Mochawesome 生成可读性更好的报告: ```bash npx cypress run --reporter mochawesome --spec 'cypress/e2e/accessibility/**' ``` 对于需要给产品经理或合规团队看的场景,HTML 报告比命令行输出实用得多。 ### 处理已知问题 项目中常有一些暂时无法修复的可访问性问题(比如第三方 SDK 内嵌的 iframe)。两种处理方式: 一是用 `skipFailures` 参数让测试不挂: ```javascript cy.checkA11y(null, null, null, true); ``` 二是用 A11y 错误日志专门记录,在代码注释中标记 issue 编号,后续跟踪修复。 ## 常见坑和排查思路 ### injectAxe 时机不对 如果页面有重定向或 SPA 路由切换,`injectAxe()` 需要在每次页面变化后重新调用: ```javascript it('SPA 路由切换后重新注入', () => { cy.visit('/'); cy.injectAxe(); cy.checkA11y(); cy.get('a[href="/about"]').click(); cy.injectAxe(); // 路由切换后重新注入 cy.checkA11y(); }); ``` ### 动态内容渲染时机 不要用 `cy.wait(1000)` 等待动态内容。Cypress 本身会自动等待 DOM 变化,配合 `.should()` 断言更可靠: ```javascript // 错误做法 cy.wait(1000); cy.checkA11y(); // 正确做法 cy.get('.dynamic-content').should('be.visible'); cy.checkA11y(); ``` ### iframe 内的检查 axe-core 默认不检查 iframe 内部内容。如果页面嵌入了 iframe,需要配置 `iframes` 选项: ```javascript cy.checkA11y(null, { iframes: true }); ``` 但跨域 iframe 受浏览器安全策略限制,无法访问。这种情况只能手动测试或通过 iframe 内部页面的独立测试覆盖。 ### 第三方组件的可访问性问题 组件库(如 MUI、Ant Design)生成的 DOM 结构可能存在 ARIA 属性缺失或错误。两种思路: 一是在组件级别写测试,而不是页面级别,缩小排查范围: ```javascript // 测试自定义封装的 DatePicker 组件 cy.mount(<DatePicker />); cy.injectAxe(); cy.checkA11y(); ``` 二是向组件库提 issue,大部分主流组件库对可访问性 bug 响应积极。 ## 自动化测试的边界 axe-core 能检测大约 30%~40% 的 WCAG 问题。以下情况必须手动验证: - 屏幕阅读器的实际朗读顺序和内容 - 键盘 Tab 顺序是否符合视觉布局的逻辑顺序 - 颜色对比度在不同显示器和亮度下的实际效果 - 视频是否提供字幕和音频描述 - 表单 placeholder 不能替代 label 的语义 自动化测试通过不代表应用完全可访问。它只是第一道关卡,后续还需要手动复核和用户测试。
服务端5月27日 23:01
Cypress 中动态元素怎么等待?显式等待、拦截请求和避坑全讲清楚写 Cypress 测试最让人头疼的不是写断言,而是页面上的元素"不听话"——点了按钮,数据还没回来;表单提交了,loading 转圈转个没完;动画还没播完,Cypress 已经报 Element not found。这些问题本质上都是动态元素等待没处理好。Cypress 自带重试机制,但光靠默认行为远远不够,需要理解它的等待原理,掌握显式等待、请求拦截、条件判断等策略,才能写出稳定不 flaky 的测试。 ## 动态元素为什么让测试频繁失败? 先搞清楚"动态元素"到底指什么。在单页应用里,大部分 UI 都是异步渲染的: - **AJAX 异步加载数据**:接口返回前 DOM 里根本没有目标元素,Cypress 找不到自然报错 - **动画和过渡效果**:元素在 DOM 里存在,但 opacity 为 0 或者正在位移,Cypress 认为它不可交互 - **条件渲染**:React/Vue 的 v-if、&& 渲染,元素可能压根没挂载 - **懒加载和虚拟列表**:滚动前元素不在视口,Cypress 无法滚动到不可见元素 Cypress 遇到这些场景默认会重试(默认 4 秒),但 4 秒够不够取决于网络和后端性能。更关键的是,有些场景不是"等久一点"就能解决的,需要用对策略。 ## Cypress 的等待原理:Retry-Ability 理解等待策略的前提是理解 Cypress 的 retry-ability 机制。Cypress 的命令不是立即执行的,而是进入一个队列,每个命令会自动重试直到断言通过或超时。 举个例子: ```javascript cy.get('#result').should('contain', '成功'); ``` 这行代码的行为是:每隔约 50ms 重新查找 `#result` 元素并检查其文本,直到包含"成功"或者超时(默认 4 秒)。这就是为什么大部分情况下你不需要手动写 wait。 但有一个关键细节:**只有最后一个断言会触发重试,中间的命令不会**。比如: ```javascript // 错误示例:click 不会重试 cy.get('#btn').click(); // 如果按钮此时不可点击,直接失败 cy.get('#result').should('be.visible'); ``` 如果 `#btn` 正好在动画中不可点击,`click()` 不会自动重试,直接报错。正确写法是: ```javascript // 正确:确保按钮可操作后再点击 cy.get('#btn').should('be.visible').click(); cy.get('#result').should('be.visible'); ``` ## 显式等待:用 should 和 then 精准控制 ### 用 should 等待状态 `should()` 是最常用也最可靠的等待方式,它会持续重试直到条件满足: ```javascript // 等待元素出现并可见 cy.get('.notification').should('be.visible'); // 等待元素消失(常用于等待 loading 结束) cy.get('.spinner').should('not.exist'); // 等待文本内容变化 cy.get('#status').should('have.text', '加载完成'); // 等待元素有特定类名 cy.get('#panel').should('have.class', 'active'); ``` ### 用 then 处理依赖关系 当后续操作依赖前一个步骤的结果时,用 `then()` 确保顺序: ```javascript // 等 loading 消失后再查找目标元素 cy.get('.loading-overlay').should('not.exist').then(() => { cy.get('.data-table').should('be.visible'); cy.get('.data-table tr').should('have.length.gt', 0); }); ``` ### 自定义超时时间 某些场景默认 4 秒不够,可以针对单个命令设置超时: ```javascript // 接口响应慢的页面,给 get 20 秒超时 cy.get('.slow-loaded-content', { timeout: 20000 }).should('be.visible'); // 也可以在 cypress.config.js 中全局修改 // 但不推荐全局改太大,会让所有测试变慢 ``` ## 用 cy.intercept 等待网络请求 等待元素状态变化本质上是"被动等待",更可靠的方式是直接等待触发变化的原因——网络请求。`cy.intercept` + `cy.wait` 组合可以精准等待 API 响应: ```javascript // 拦截请求并起别名 cy.intercept('GET', '/api/users').as('getUsers'); cy.intercept('POST', '/api/login').as('login'); // 触发操作 cy.visit('/dashboard'); cy.get('#loginBtn').click(); // 等待特定请求完成 cy.wait('@login'); cy.wait('@getUsers'); // 然后再验证 UI cy.get('.user-list').should('be.visible'); ``` ### 更精细的请求等待 可以验证请求的参数和响应: ```javascript cy.wait('@login').then((interception) => { expect(interception.request.body).to.have.property('username'); expect(interception.response.statusCode).to.eq(200); }); // 等待多个同名请求全部完成 cy.wait(['@getUsers', '@getUsers']); ``` ### 用 intercept 模拟后端响应 测试不应该依赖后端状态,用 intercept 可以直接 mock 响应,彻底消除等待的不确定性: ```javascript // 模拟成功响应 cy.intercept('GET', '/api/users', { statusCode: 200, body: [{ id: 1, name: '张三' }, { id: 2, name: '李四' }] }).as('getUsers'); // 模拟延迟响应(测试 loading 状态) cy.intercept('GET', '/api/users', { statusCode: 200, body: [], delayMs: 3000 }).as('getUsersSlow'); // 模拟错误响应 cy.intercept('GET', '/api/users', { statusCode: 500, body: { error: 'Internal Server Error' } }).as('getUsersError'); ``` ## 条件等待:处理不确定的场景 有些场景下,元素可能出现也可能不出现(比如弹窗提示),这时候不能用简单的 should,因为找不到元素会直接报错。Cypress 没有原生的 if/else 条件判断,但可以用 `then` 配合 jQuery 判断: ```javascript // 判断弹窗是否出现,出现了就关闭 cy.get('body').then(($body) => { if ($body.find('.cookie-banner').length > 0) { cy.get('.cookie-banner .close-btn').click(); } }); ``` 注意这种写法的局限:它只检查一次,不会重试。如果弹窗是异步出现的,可能判断时还没渲染。解决办法是配合 should 确保前置条件: ```javascript // 确保页面加载完成后再判断 cy.get('.main-content').should('be.visible'); cy.get('body').then(($body) => { if ($body.find('.notification').length > 0) { cy.get('.notification .dismiss').click(); } }); ``` ## 常见坑和排错思路 ### 坑 1:用 cy.wait(数字) 硬编码等待 ```javascript // 千万别这么写 cy.wait(5000); // 有时候 5 秒也不够,有时候白等 5 秒 cy.get('.result').should('be.visible'); ``` 用 `should` 替代,让 Cypress 按需等待: ```javascript cy.get('.result').should('be.visible'); // 快的话立即通过,慢的最多等超时 ``` ### 坑 2:在 should 之前用了不重试的命令 ```javascript // type 不会重试,如果 input 还没 ready 就会失败 cy.get('#search').type('关键词'); cy.get('#search').should('have.value', '关键词'); ``` 改成确保元素可交互: ```javascript cy.get('#search').should('be.visible').and('not.be.disabled').type('关键词'); ``` ### 坑 3:多个异步操作没有全部等待 ```javascript // 页面发了 3 个请求,只等了 1 个 cy.intercept('GET', '/api/profile').as('profile'); cy.intercept('GET', '/api/orders').as('orders'); cy.intercept('GET', '/api/settings').as('settings'); cy.visit('/account'); cy.wait('@profile'); // 只等了 profile,orders 和 settings 可能还没回来 ``` 应该等待所有请求: ```javascript cy.wait(['@profile', '@orders', '@settings']); ``` ### 坑 4:should 断言了不该断言的内容 ```javascript // 不好:断言太多,分不清是哪个失败 cy.get('#card') .should('be.visible') .and('have.class', 'loaded') .and('contain', '数据') .and('not.have.class', 'error'); ``` 拆开写,失败信息更清晰: ```javascript cy.get('#card').should('be.visible'); cy.get('#card').should('have.class', 'loaded'); cy.get('#card').should('contain', '数据'); cy.get('#card').should('not.have.class', 'error'); ``` ## 完整实战示例 下面是一个典型的动态页面测试场景,综合运用以上所有策略: ```javascript describe('订单列表页面', () => { beforeEach(() => { // 拦截所有接口 cy.intercept('GET', '/api/orders*', { fixture: 'orders.json' }).as('getOrders'); cy.intercept('GET', '/api/user/profile', { fixture: 'profile.json' }).as('getProfile'); }); it('加载完成后显示订单列表', () => { cy.visit('/orders'); // 等待两个请求都完成 cy.wait(['@getOrders', '@getProfile']); // loading 消失 cy.get('.skeleton-loader').should('not.exist'); // 数据表格出现且有内容 cy.get('.order-table').should('be.visible'); cy.get('.order-table tbody tr').should('have.length.gt', 0); }); it('筛选后重新加载数据', () => { cy.visit('/orders'); cy.wait('@getOrders'); // 重新拦截,模拟筛选结果 cy.intercept('GET', '/api/orders*status=completed*', { fixture: 'orders-completed.json' }).as('getCompleted'); // 操作筛选器 cy.get('#status-filter').should('be.visible').select('completed'); // 等待筛选请求完成 cy.wait('@getCompleted'); // 验证列表已更新 cy.get('.order-table tbody tr').should('have.length', 3); cy.get('.order-table').should('contain', '已完成'); }); it('接口报错时显示错误提示', () => { // 模拟接口异常 cy.intercept('GET', '/api/orders*', { statusCode: 500, body: { message: '服务器错误' } }).as('getOrdersError'); cy.visit('/orders'); cy.wait('@getOrdersError'); // 验证错误提示 cy.get('.error-banner').should('be.visible'); cy.get('.error-banner').should('contain', '加载失败'); // 点击重试 cy.intercept('GET', '/api/orders*', { fixture: 'orders.json' }).as('getOrdersRetry'); cy.get('.retry-btn').click(); cy.wait('@getOrdersRetry'); // 错误提示消失,数据正常显示 cy.get('.error-banner').should('not.exist'); cy.get('.order-table').should('be.visible'); }); }); ``` ## 策略选择速查 | 场景 | 推荐策略 | 示例 | |------|----------|------| | 元素异步出现 | should('be.visible') | cy.get('#el').should('be.visible') | | loading 消失后操作 | should('not.exist') + then | cy.get('.loading').should('not.exist').then(...) | | 等待接口响应 | intercept + wait | cy.wait('@apiCall') | | 条件判断元素存在 | body.then + jQuery find | $body.find('.el').length > 0 | | 响应慢的页面 | 增加单命令超时 | cy.get('#el', { timeout: 20000 }) | | mock 后端数据 | intercept + fixture | cy.intercept('GET', '/api', { fixture }) | 掌握这些策略的核心思路:**优先等待原因(网络请求),而不是等待结果(UI 变化);用断言驱动重试,而不是硬编码等待时间**。这样写出来的测试既快又稳,不会因为网络波动或动画时序而随机失败。
服务端5月27日 23:01
Cypress 中怎么做表单测试?## 表单测试要测什么 表单是用户和系统交互的主要入口,测试不到位直接影响业务。一个注册表单如果邮箱校验没测到,上线后用户可能注册失败;一个支付表单如果金额边界没覆盖,可能导致资金问题。 Cypress 做表单测试,核心就三件事:定位元素、模拟输入、验证结果。但实际写起来,动态渲染、异步校验、跨域接口这些坑一个接一个。下面按实际开发流程一步步来。 ## 环境准备 安装和启动没什么特别的: ```bash npm install cypress --save-dev npx cypress open ``` 注意一点:本地测试环境和生产环境的表单行为可能不同,尤其是验证逻辑和接口响应。测试数据尽量用 fixture 管理,不要硬编码在用例里。 ## 定位表单元素 元素定位是表单测试的第一步,也是最容易出问题的一步。选择器写得不好,页面一改测试就挂。 ### 优先用 data-testid ```javascript cy.get('[data-testid="username-input"]') .type('testuser'); ``` `data-testid` 是最稳定的定位方式,不受样式和 DOM 结构变化影响。 ### CSS 选择器能不用就不用 ```javascript // 这种写法脆弱,class 一改就挂 cy.get('.form-control input[type="text"]') .should('be.empty'); ``` ### 绝对不要用的选择器 - `#id`:ID 可能在重构时被移除 - `div > span > input`:DOM 层级一变就全挂 - `:nth-child()`:顺序一调就完蛋 面试中经常问「选择器优先级」,回答 `data-testid` > `CSS class` > `id` > `DOM 结构` 基本没问题。 ## 输入和校验 ### 基本输入 ```javascript cy.get('[data-testid="email-input"]').type('test@example.com'); cy.get('[data-testid="email-input"]').should('have.value', 'test@example.com'); ``` ### 实时校验的验证 很多表单有实时校验,比如邮箱格式输入过程中就提示错误: ```javascript cy.get('[data-testid="email-input"]').type('invalid-email'); cy.contains('请输入有效的邮箱地址').should('be.visible'); ``` 这里用 `cy.contains()` 比用 `cy.get()` 找错误提示更可靠,因为错误提示的 DOM 结构可能变化,但文本内容相对稳定。 ### 密码字段的处理 ```javascript cy.get('[type="password"]').type('MyPassword123!'); ``` 密码字段不要用 `should('have.value')` 去断言内容,因为有些浏览器安全策略会干扰。断言 `should('have.prop', 'type', 'password')` 确认类型就够了。 ### 下拉框和单选框 ```javascript // 下拉框选择 cy.get('select#city').select('北京'); // 单选框 cy.get('[type="radio"]').check('option1'); // 复选框 cy.get('[type="checkbox"]').check(); ``` ### 清除输入 ```javascript cy.get('[data-testid="username-input"]').clear(); ``` 注意 `clear()` 在某些自定义输入框上可能不生效,这时可以试 `type('{selectall}{backspace}')` 代替。 ## 表单提交和异步处理 ### 直接提交 ```javascript cy.get('button[type="submit"]').click(); cy.url().should('include', '/success'); ``` ### 用 intercept 拦截接口 这是面试高频考点。表单提交通常会调接口,测试不应该依赖真实后端: ```javascript cy.intercept('POST', '/api/register').as('register'); cy.get('button[type="submit"]').click(); cy.wait('@register').its('response.statusCode').should('eq', 200); ``` ### 模拟接口返回 不止拦截,还可以模拟后端返回不同场景: ```javascript // 模拟注册成功 cy.intercept('POST', '/api/register', { statusCode: 200, body: { message: '注册成功' } }); // 模拟邮箱已存在 cy.intercept('POST', '/api/register', { statusCode: 409, body: { error: '邮箱已被注册' } }); ``` 这种能力让测试可以覆盖各种边界场景,不依赖后端状态。 ## 边界场景测试 面试里最加分的就是边界场景,只测正常流程的测试用例没什么含金量。 ### 空值提交 ```javascript cy.get('button[type="submit"]').click(); cy.contains('必填字段不能为空').should('be.visible'); ``` ### 超长输入 ```javascript const longText = 'a'.repeat(300); cy.get('[data-testid="username-input"]').type(longText); // 验证是否有长度限制提示 cy.contains('不能超过').should('be.visible'); ``` ### 特殊字符 ```javascript cy.get('[data-testid="username-input"]').type('<script>alert(1)</script>'); // 确认 XSS 被正确处理 ``` ### 文件上传 ```javascript cy.get('[type="file"]').attachFile({ filePath: 'test.pdf' }); cy.get('.upload-success').should('be.visible'); ``` 文件上传需要安装 `cypress-file-upload` 插件。 ### 用 fixture 管理测试数据 ```javascript cy.fixture('user').then((user) => { cy.get('[data-testid="username-input"]').type(user.name); cy.get('[data-testid="email-input"]').type(user.email); }); ``` `cypress/fixtures/user.json` 里维护测试数据,多套数据方便覆盖不同场景。 ## 常见坑和解决办法 ### 元素加载延迟导致测试失败 Cypress 自带重试机制,但有时候还是不够: ```javascript // 不推荐:硬等 cy.wait(3000); // 推荐:断言驱动等待 cy.get('[data-testid="form"]').should('be.visible'); cy.get('[data-testid="password-input"]').type('password123'); ``` 原则:能用断言等待就不要用 `cy.wait(时间)`。 ### 跨域接口问题 Cypress 对跨域请求有限制,但测试中又经常需要调不同域的接口: ```javascript cy.intercept('POST', 'https://api.other-domain.com/login', { body: { token: 'valid' } }).as('login'); ``` 用 `intercept` 拦截跨域请求并模拟返回,绕过跨域问题。 ### 日期选择器测试困难 很多日期选择器用了自定义渲染,原生 `type()` 打不进去: ```javascript // 方案1:直接赋值(绕过 UI) cy.get('input[type="date"]').invoke('val', '2025-06-01').trigger('change'); // 方案2:用 force 覆盖可见性检查 cy.get('.datepicker-input').type('2025-06-01', { force: true }); ``` ### 自定义输入框 clear() 不生效 有些组件库的输入框不是原生 input,`clear()` 不起作用: ```javascript // 替代方案 cy.get('[data-testid="search-input"]').type('{selectall}{backspace}'); ``` ## 自定义命令封装复用逻辑 如果多个用例都要填同一个表单,封装成自定义命令: ```javascript // cypress/support/commands.js Cypress.Commands.add('fillLoginForm', (username, password) => { cy.get('[data-testid="username-input"]').type(username); cy.get('[data-testid="password-input"]').type(password); }); // 用例中使用 it('登录成功', () => { cy.fillLoginForm('admin', 'password123'); cy.get('button[type="submit"]').click(); cy.url().should('include', '/dashboard'); }); ``` 命令封装让用例更简洁,改起来也只改一处。 ## 测试的组织和运行 ### 用 before/beforeEach 准备数据 ```javascript describe('注册表单测试', () => { beforeEach(() => { cy.visit('/register'); }); it('正常注册', () => { /* ... */ }); it('邮箱为空提示错误', () => { /* ... */ }); it('密码强度不足提示错误', () => { /* ... */ }); }); ``` 每个 it 块保持独立,不依赖其他用例的执行结果。 ### 并行执行 ```bash npx cypress run --parallel ``` 并行执行要注意:测试用例之间不能有状态依赖,否则并行时会出现随机失败。 ## 面试高频问题速查 1. **Cypress 做表单测试的核心步骤?** 定位元素、模拟输入、提交表单、验证结果。 2. **为什么优先用 data-testid?** 稳定,不受样式和 DOM 结构变化影响。 3. **cy.intercept 和 cy.route 的区别?** `cy.route` 是旧 API,Cypress 6+ 已废弃;`cy.intercept` 支持拦截和修改请求/响应,功能更强大。 4. **怎么测试异步校验?** 用 `cy.intercept` 拦截校验接口,`cy.wait` 等待响应,再断言 UI 反馈。 5. **表单测试怎么处理跨域?** 用 `cy.intercept` 模拟返回绕过跨域,或者在 `cypress.config.js` 配置 `e2e.experimentalOriginDependencies`。 6. **Cypress 的自动重试机制和手动 wait 怎么选?** 优先用断言驱动等待(`should`),只在断言无法覆盖的场景(如动画)才用 `cy.wait()`。 7. **如何测试文件上传?** 安装 `cypress-file-upload` 插件,使用 `attachFile()` 方法。 8. **自定义输入框 clear() 不生效怎么办?** 用 `type('{selectall}{backspace}')` 替代。 9. **怎么管理多套测试数据?** 用 `cy.fixture()` 加载 JSON 文件,不同场景用不同 fixture。 10. **表单测试常见的边界场景有哪些?** 空值提交、超长输入、特殊字符/XSS、并发提交、网络超时。
服务端5月27日 23:00
Cypress 数据驱动测试怎么实现?从 fixture 到实战的完整方案Cypress 的数据驱动测试能让你用同一套测试逻辑跑多组数据,避免为每种输入单独写用例。比如测试登录,与其写 5 个几乎相同的 it 块分别测试不同账号,不如把账号数据抽到 fixtures 文件,用一个循环搞定。本文从 `cy.fixture()` 基础用法讲起,覆盖 `.each()` 遍历、动态数据源、常见踩坑和最佳实践。 ## 用 cy.fixture() 加载测试数据 ### fixture 文件怎么写 Cypress 的 fixtures 目录默认在 `cypress/fixtures/`,数据格式用 JSON。创建一个登录用的测试数据文件: ```json // cypress/fixtures/users.json [ { "username": "admin", "password": "admin123", "expectSuccess": true }, { "username": "guest", "password": "wrong", "expectSuccess": false }, { "username": "locked_user", "password": "pass123", "expectSuccess": false } ] ``` 每个数据项里除了输入值,还加了期望结果的字段。这样正负用例都能覆盖,数据本身就表达了测试意图。 ### 在测试中加载 fixture `cy.fixture()` 加载 fixtures 目录下的 JSON 文件,返回解析后的数据。最基础的写法: ```javascript describe('登录功能 - 数据驱动', () => { it('用 fixture 数据验证多种账号', () => { cy.fixture('users.json').then((users) => { users.forEach((user) => { cy.visit('/login') cy.get('#username').clear().type(user.username) cy.get('#password').clear().type(user.password) cy.get('button[type="submit"]').click() if (user.expectSuccess) { cy.url().should('include', '/dashboard') } else { cy.get('.error-message').should('be.visible') } }) }) }) }) ``` 这里有个实际问题:`forEach` 在一个 it 块里跑多组数据,如果中间某组失败,Cypress 会直接中断,后面的数据组不会执行。要解决这个问题,得换一种方式。 ## 用 .each() 替代 forEach ### 为什么 forEach 不够好 `forEach` 不是 Cypress 命令,它不会进入 Cypress 的命令队列。这意味着: - 某组数据断言失败后,剩余数据直接跳过 - 无法利用 Cypress 的重试机制 - 调试时很难定位是哪组数据出了问题 ### 用 Cypress .each() 逐条执行 Cypress 的 `.each()` 是一个命令,每条数据生成独立的命令序列,失败行为更可控: ```javascript describe('登录功能 - 数据驱动', () => { beforeEach(() => { cy.visit('/login') }) it('验证多种账号的登录结果', () => { cy.fixture('users.json').then((users) => { cy.wrap(users).each((user) => { cy.visit('/login') cy.get('#username').clear().type(user.username) cy.get('#password').clear().type(user.password) cy.get('button[type="submit"]').click() if (user.expectSuccess) { cy.url().should('include', '/dashboard') } else { cy.get('.error-message').should('be.visible') } }) }) }) }) ``` `cy.wrap(users).each()` 把数组包装成 Cypress 对象再遍历,每条数据都在命令队列里排队执行。 ### 更推荐:每个 it 块跑一条数据 如果想让每组数据完全独立(一条失败不影响其他),把数据驱动拆到 it 层面更合适: ```javascript describe('登录功能 - 数据驱动', () => { let users before(() => { cy.fixture('users.json').then((data) => { users = data }) }) users.forEach((user, index) => { it(`账号 ${user.username} 登录测试`, () => { cy.visit('/login') cy.get('#username').type(user.username) cy.get('#password').type(user.password) cy.get('button[type="submit"]').click() if (user.expectSuccess) { cy.url().should('include', '/dashboard') } else { cy.get('.error-message').should('be.visible') } }) }) }) ``` 这种方式下,Cypress 报告里每条数据都有独立的测试用例名,失败定位一目了然。需要注意的是 `before` 里加载 fixture,`forEach` 在 describe 层面展开 it 块,这是 Cypress 社区推荐的模式。 ## 从 API 动态获取测试数据 不是所有测试数据都适合写死在 fixture 文件里。比如你要测的用户列表经常变动,可以用 `cy.request()` 从接口拿数据: ```javascript describe('API 数据驱动', () => { it('从接口获取数据并验证', () => { cy.request('GET', '/api/test-users').then((response) => { expect(response.status).to.eq(200) const users = response.body cy.wrap(users).each((user) => { cy.visit('/login') cy.get('#username').type(user.username) cy.get('#password').type(user.password) cy.get('button[type="submit"]').click() cy.get('.welcome').should('contain', user.username) }) }) }) }) ``` 几个注意点: - 确保 `/api/test-users` 接口稳定,否则测试会因为数据获取失败而挂掉 - 数据量大时考虑截取前 N 条,避免测试运行时间过长:`const users = response.body.slice(0, 10)` - 可以在 `before` 里请求一次数据,后续 it 块复用,减少重复请求 ## 常见踩坑 ### fixture 文件路径写错 `cy.fixture('users')` 和 `cy.fixture('users.json')` 都能工作,Cypress 会自动补全扩展名。但如果你的 fixtures 目录有子目录,路径要写全:`cy.fixture('auth/users')` 对应 `cypress/fixtures/auth/users.json`。 ### 数据驱动测试跑得慢 每组数据都要重新走一遍页面交互,数据多了自然慢。几个优化方向: - 减少不必要的 `cy.visit()`,如果页面状态可以重置,用 `cy.reload()` 更快 - 只保留核心场景数据,边界数据挑有代表性的几条就够了 - 用 `cy.session()` 缓存登录状态,避免每次重新走登录流程 ### forEach 里状态没清理 在循环里跑登录测试,上一条数据的输入残留在页面上,导致下一条数据输入错乱。解决方法是在每轮循环开始时清理字段: ```javascript cy.wrap(users).each((user) => { cy.visit('/login') // 重新访问页面,相当于重置状态 // 或者手动清理: // cy.get('#username').clear() // cy.get('#password').clear() cy.get('#username').type(user.username) cy.get('#password').type(user.password) cy.get('button[type="submit"]').click() }) ``` ## 数据驱动测试的最佳实践 **数据与逻辑分离**:fixture 文件只放数据,测试脚本只管逻辑。数据文件纳入版本控制,修改数据不影响测试代码。 **覆盖正负场景**:数据集里同时包含成功和失败的用例。很多团队只测 happy path,失败场景反而更容易出问题。 **命名要清晰**:fixture 文件名和每个 it 块的描述都要能直接看出测的是什么。`账号 locked_user 登录测试` 比 `第 3 条数据测试` 有用得多。 **控制数据规模**:数据不是越多越好。5 到 10 条覆盖核心场景的数据比 50 条冗余数据更实用,跑起来也更快。 **接口数据做好兜底**:用 `cy.request()` 拿数据时,加一个状态码断言确保数据源没问题,别让接口异常拖垮整个测试套件。 数据驱动测试的本质是让测试逻辑写一次、数据跑多遍。Cypress 提供了 `cy.fixture()`、`.each()`、`cy.request()` 这几件工具,组合起来能覆盖大部分场景。从 fixture 文件开始试,遇到动态数据再引入 `cy.request()`,遇到调试困难就拆成独立 it 块——按这个顺序推进,基本不会踩大坑。