Cypress面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月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 时,钩子的执行顺序遵循"从外到内"原则: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 保证每个用例从相同初始状态运行,测试更稳定。
服务端阅读 05月27日 23:18

Cypress 自定义命令怎么用?

Cypress 自定义命令(Custom Commands)是通过 Cypress.Commands.add() 在 cypress/support/commands.js 中注册的可复用测试函数,调用方式与 cy.visit() 等内置命令一致,核心目的是消除跨用例的重复代码。创建自定义命令在 cypress/support/commands.js 中定义: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()子命令:必须链式接在前一个命令后,对获取到的元素操作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() 改写内置命令行为:Cypress.Commands.overwrite('visit', (originalFn, url, options) => { return originalFn(url, { ...options, headers: { Authorization: 'Bearer ...' } });});TypeScript 类型支持在 cypress/support/index.d.ts 中声明类型,避免 TS 报错: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)避免冲突。
服务端阅读 05月27日 23:17

Cypress 如何处理跨域问题?

答案Cypress 处理跨域问题有两种主要方式:禁用 Chrome Web 安全:在 cypress.config.js 中设置 chromeWebSecurity: false,允许跨域导航和访问跨域 iframe。这是最简单的方式,但仅适用于 Chromium 内核浏览器。使用 cy.origin() 命令:从 Cypress 9.6.0 起,可通过 cy.origin() 在不同域上执行操作,Cypress 会为新源创建 iframe 并通过 postMessage 通信。这是官方推荐的跨域测试方案。// 方式一:禁用 Chrome Web 安全// cypress.config.jsmodule.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() 的注意事项?回调函数内无法直接引用外部作用域的变量,需通过第二个参数传入: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 反向代理示例location /external-api/ { proxy_pass https://api.example.com/;}
服务端阅读 05月27日 23:04

Cypress 的测试钩子(before、after、beforeEach、afterEach)如何使用?

Cypress 基于 Mocha 框架,提供了四个测试钩子来控制测试生命周期:before、after、beforeEach 和 afterEach。它们让你可以在测试执行前后插入初始化和清理逻辑,避免在每个测试用例中重复编写相同的准备代码。理解它们的区别很简单——before/after 在整个 describe 块中只跑一次,beforeEach/afterEach 在每个 it 用例前后各跑一次。四个钩子的执行顺序先看一段代码,搞清楚它们到底谁先谁后: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 A4. afterEach ← 用例 A 后2. beforeEach ← 用例 B 前3. it B4. afterEach ← 用例 B 后5. after ← 整个套件结束后,执行一次记住这条链路:before → (beforeEach → it → afterEach) × N → after,所有关于钩子的问题都能用这条链路解释。before:整个套件只执行一次的前置操作before 适合做那些"只需要做一次"的初始化工作。典型场景:访问被测页面 cy.visit('/login')用 cy.session() 建立全局登录态通过 cy.request() 预设后端数据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 执行完之后运行,适合做全局清理。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 用例运行前都有一个干净、一致的初始状态,是测试隔离的核心手段。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 用例结束后执行,适合做用例级别的清理或结果校验。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 嵌套时,内层会继承外层的所有钩子,并且外层钩子先于内层钩子执行: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 上的值,这个修改会持续影响后面的用例: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/awaitCypress 的 cy 命令不是 Promise,不能直接用 await。在钩子中如果需要等待 cy 命令完成,直接链式调用即可,不要加 async:// ❌ 错误:cy.visit 不会被 await 正确等待before(async () => { await cy.visit('/login');});// ✅ 正确:直接链式调用before(() => { cy.visit('/login');});3. after/afterEach 不执行的边界情况当测试中途失败或被手动中断时,after 和 afterEach 可能不会执行。如果你的清理逻辑很关键(比如删除测试数据),不要只放在 after 中,而是在 before 中先做一次清理:describe('健壮的清理策略', () => { before(() => { // 先清理上次可能残留的数据,再初始化本次数据 cy.request('DELETE', '/api/test-data'); cy.request('POST', '/api/test-data', { name: 'test' }); }); after(() => { // 正常结束时也清理 cy.request('DELETE', '/api/test-data'); });});四个钩子的完整协作示例把四个钩子放在一起,看看它们在实际项目中如何配合: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。
服务端阅读 05月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 全部缓存起来,后续测试直接恢复,不再重复登录。基本用法// cypress/support/commands.jsCypress.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') })})在测试中使用: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 每次恢复会话前都会先跑这个验证,失败了就重新登录: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 拿 tokenUI 登录慢,而且容易因为页面改动而断裂。对于测试来说,更稳的做法是直接调 API 拿 token,然后注入到浏览器中。基于 JWT 的程序化登录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 里:{ "TEST_USERNAME": "testuser@example.com", "TEST_PASSWORD": "securepassword"}这种方式比 UI 登录快好几倍,而且不受页面样式变化影响。处理 OAuth / 第三方登录Cypress 官方不建议在测试中去操作第三方登录页面(比如 Google、GitHub 的 OAuth 页面),因为那些页面不受你控制,随时可能改版导致测试失败。正确的做法是程序化获取 token:// 以 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 来管理多个角色:// 管理员登录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 权限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 来操作它们。读取和验证// 验证认证 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 标记})// 操作 localStoragecy.window().then((win) => { const token = win.localStorage.getItem('auth_token') expect(token).to.not.be.null})测试隔离:每个用例前清理状态beforeEach(() => { cy.clearCookies() cy.clearLocalStorage()})这一步很重要。如果不清理,上一个测试的登录状态可能"泄漏"到下一个测试,导致本应失败的测试意外通过。常见坑和解决方案坑 1:cy.session() 后忘记 cy.visit()cy.session() 执行后会重置页面到 about:blank。如果你直接在后面断言页面元素,一定会失败。必须在 cy.session() 之后、断言之前调用 cy.visit()。// 错误写法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 环境变量来管理:// 错误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() 来处理跨域操作: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:测试间状态泄漏如果某个测试修改了用户角色或权限,后续测试可能因为这个修改而行为异常。解决办法是确保每个测试有独立的初始状态:// 用 cy.session() 自动隔离// 用 cy.database() 或 cy.task() 重置测试数据before(() => { cy.task('db:seed')})完整示例:一个认证测试套件把上面的内容整合起来,一个实际项目中可用的认证测试套件大概长这样:// cypress/support/commands.jsCypress.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) } })})// cypress/e2e/auth.cy.jsdescribe('认证流程', () => { 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 官方指南 和 cy.session() API 文档。
服务端阅读 05月27日 23:02

Cypress 如何管理环境变量和配置?

Cypress 测试要在开发、测试、预发布、生产等多个环境中跑,每个环境的 API 地址、账号密码、超时阈值都不一样。如果把这些值硬编码在测试代码里,换个环境就全崩了——这正是环境变量和配置管理要解决的问题。Cypress 提供了一套分层的环境变量体系,优先级从高到低依次是:命令行 --env 参数 > CYPRESS_ 前缀系统变量 > cypress.env.json 文件 > cypress.config.js 中的 env 字段。理解这套优先级,才能知道变量到底从哪来、被谁覆盖了。下面逐层拆解。cypress.config.js 中定义默认环境变量cypress.config.js 是 Cypress 的主配置入口,在 env 字段中可以声明所有环境变量的默认值: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 变量:{ "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 都会自动识别并注入。变量名会去掉前缀并转为大写:# 设置系统环境变量export CYPRESS_API_BASE_URL=https://prod.api.example.comexport CYPRESS_ADMIN_PASSWORD=secret123# 运行测试npx cypress run在测试中通过 Cypress.env('API_BASE_URL') 就能拿到值。这个机制特别适合 CI 环境——在 CI 平台的安全变量配置里设置 CYPRESS_ 前缀变量,测试运行时自动生效,不需要额外代码。命令行 --env 参数:优先级最高的覆盖方式--env 参数的优先级最高,会覆盖上面所有来源的同名变量:# 传递单个变量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_secretCI 管道中经常这样用:构建脚本根据目标环境动态拼接 --env 参数,实现一套代码跑多套环境。在测试代码中读取和临时修改环境变量读取:Cypress.env()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() 会返回所有环境变量的对象,方便一次性取多个值。临时修改:运行时覆盖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各环境配置文件内容示例:{ "baseUrl": "https://staging.example.com", "env": { "API_BASE_URL": "https://staging.api.example.com", "TIMEOUT_MS": 15000 }}然后在 package.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 可以直接复用:npm install dotenv --save-dev// cypress.config.jsconst { 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 示例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 runGitLab CI 示例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' })。
服务端阅读 05月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 规则引擎。安装分三步:第一步,装包:npm install cypress-axe --save-dev第二步,在 cypress/support/e2e.js 中引入:import 'cypress-axe';第三步,在每个测试前注入 axe-core 到页面。cypress-axe 提供了 cy.injectAxe() 命令,通常放在 beforeEach 里: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-axeinjectAxe() 必须在页面加载之后调用,否则找不到 document 对象cy.checkA11y() 的基本用法和参数全页面扫描最简单的用法,检查整个页面:it('登录页没有可访问性问题', () => { cy.visit('/login'); cy.injectAxe(); cy.checkA11y();});测试失败时,Cypress 命令行会输出每个违规项的详细信息:规则 ID、影响级别(critical / serious / moderate / minor)、违规元素选择器、修复建议。指定扫描范围只检查某个容器内的元素:cy.checkA11y('.main-content');// 或者用 Cypress 链式查找cy.get('form').checkA11y();自定义规则和运行参数checkA11y 的第二个参数是 axe 的配置对象:cy.checkA11y(null, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] // 只检查 WCAG 2.x A 和 AA 级别 }, rules: { 'color-contrast': { enabled: false }, // 暂时跳过颜色对比度 'region': { enabled: true } // 强制检查地标区域 }});排除特定元素第三方组件或广告区域可能无法修改,可以排除:cy.checkA11y({ exclude: ['.ad-banner', '#third-party-widget']});自定义违规回调不希望测试直接失败,而是收集违规信息做进一步处理:cy.checkA11y(null, null, (violations) => { violations.forEach((v) => { cy.log(`${v.id}: ${v.nodes.length} 个元素违规`); });}, true); // 第四个参数 skipFailures = true,不导致测试失败实际项目中的测试策略按页面或功能模块编写测试不建议把所有可访问性检查塞进一个巨大的测试文件。按页面拆分更清晰:// cypress/e2e/accessibility/login.spec.jsdescribe('登录页可访问性', () => { 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 无法检测所有键盘交互问题。需要手动编写测试来补充: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 属性做显式断言,比依赖自动扫描更可靠: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-a11ywick-a11y 是 cypress-axe 的替代方案,提供了更清晰的 HTML 报告和截图标注:npm install -D wick-a11y// cypress/support/e2e.jsimport 'wick-a11y';使用 cy.checkAccessibility() 代替 cy.checkA11y():it('首页可访问性检查', () => { cy.visit('/'); cy.checkAccessibility();});wick-a11y 的优势在于测试失败时直接在 Cypress 截图上标注违规元素位置,比纯文本日志更容易定位问题。Cypress Accessibility Cloud2025 年 Cypress 推出了 Cypress Accessibility 平台,集成在 Cypress Cloud 中。它不需要额外安装插件,而是基于已有的测试录制自动生成可访问性报告。使用方式很简单:只要测试运行时开启了云录制,Cypress Cloud 会自动分析页面快照,标记可访问性问题。这对不想维护额外测试代码的团队是个低门槛选项。不过它目前只覆盖部分 WCAG 规则,深度检查仍然需要 cypress-axe 或 wick-a11y。CI 集成和报告GitHub Actions 配置name: E2E with A11yon: [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 生成可读性更好的报告:npx cypress run --reporter mochawesome --spec 'cypress/e2e/accessibility/**'对于需要给产品经理或合规团队看的场景,HTML 报告比命令行输出实用得多。处理已知问题项目中常有一些暂时无法修复的可访问性问题(比如第三方 SDK 内嵌的 iframe)。两种处理方式:一是用 skipFailures 参数让测试不挂:cy.checkA11y(null, null, null, true);二是用 A11y 错误日志专门记录,在代码注释中标记 issue 编号,后续跟踪修复。常见坑和排查思路injectAxe 时机不对如果页面有重定向或 SPA 路由切换,injectAxe() 需要在每次页面变化后重新调用:it('SPA 路由切换后重新注入', () => { cy.visit('/'); cy.injectAxe(); cy.checkA11y(); cy.get('a[href="/about"]').click(); cy.injectAxe(); // 路由切换后重新注入 cy.checkA11y();});动态内容渲染时机不要用 cy.wait(1000) 等待动态内容。Cypress 本身会自动等待 DOM 变化,配合 .should() 断言更可靠:// 错误做法cy.wait(1000);cy.checkA11y();// 正确做法cy.get('.dynamic-content').should('be.visible');cy.checkA11y();iframe 内的检查axe-core 默认不检查 iframe 内部内容。如果页面嵌入了 iframe,需要配置 iframes 选项:cy.checkA11y(null, { iframes: true});但跨域 iframe 受浏览器安全策略限制,无法访问。这种情况只能手动测试或通过 iframe 内部页面的独立测试覆盖。第三方组件的可访问性问题组件库(如 MUI、Ant Design)生成的 DOM 结构可能存在 ARIA 属性缺失或错误。两种思路:一是在组件级别写测试,而不是页面级别,缩小排查范围:// 测试自定义封装的 DatePicker 组件cy.mount(<DatePicker />);cy.injectAxe();cy.checkA11y();二是向组件库提 issue,大部分主流组件库对可访问性 bug 响应积极。自动化测试的边界axe-core 能检测大约 30%~40% 的 WCAG 问题。以下情况必须手动验证:屏幕阅读器的实际朗读顺序和内容键盘 Tab 顺序是否符合视觉布局的逻辑顺序颜色对比度在不同显示器和亮度下的实际效果视频是否提供字幕和音频描述表单 placeholder 不能替代 label 的语义自动化测试通过不代表应用完全可访问。它只是第一道关卡,后续还需要手动复核和用户测试。
服务端阅读 05月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 的命令不是立即执行的,而是进入一个队列,每个命令会自动重试直到断言通过或超时。举个例子:cy.get('#result').should('contain', '成功');这行代码的行为是:每隔约 50ms 重新查找 #result 元素并检查其文本,直到包含"成功"或者超时(默认 4 秒)。这就是为什么大部分情况下你不需要手动写 wait。但有一个关键细节:只有最后一个断言会触发重试,中间的命令不会。比如:// 错误示例:click 不会重试cy.get('#btn').click(); // 如果按钮此时不可点击,直接失败cy.get('#result').should('be.visible');如果 #btn 正好在动画中不可点击,click() 不会自动重试,直接报错。正确写法是:// 正确:确保按钮可操作后再点击cy.get('#btn').should('be.visible').click();cy.get('#result').should('be.visible');显式等待:用 should 和 then 精准控制用 should 等待状态should() 是最常用也最可靠的等待方式,它会持续重试直到条件满足:// 等待元素出现并可见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() 确保顺序:// 等 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 秒不够,可以针对单个命令设置超时:// 接口响应慢的页面,给 get 20 秒超时cy.get('.slow-loaded-content', { timeout: 20000 }).should('be.visible');// 也可以在 cypress.config.js 中全局修改// 但不推荐全局改太大,会让所有测试变慢用 cy.intercept 等待网络请求等待元素状态变化本质上是"被动等待",更可靠的方式是直接等待触发变化的原因——网络请求。cy.intercept + cy.wait 组合可以精准等待 API 响应:// 拦截请求并起别名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');// 然后再验证 UIcy.get('.user-list').should('be.visible');更精细的请求等待可以验证请求的参数和响应: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 响应,彻底消除等待的不确定性:// 模拟成功响应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 判断:// 判断弹窗是否出现,出现了就关闭cy.get('body').then(($body) => { if ($body.find('.cookie-banner').length > 0) { cy.get('.cookie-banner .close-btn').click(); }});注意这种写法的局限:它只检查一次,不会重试。如果弹窗是异步出现的,可能判断时还没渲染。解决办法是配合 should 确保前置条件:// 确保页面加载完成后再判断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(数字) 硬编码等待// 千万别这么写cy.wait(5000); // 有时候 5 秒也不够,有时候白等 5 秒cy.get('.result').should('be.visible');用 should 替代,让 Cypress 按需等待:cy.get('.result').should('be.visible'); // 快的话立即通过,慢的最多等超时坑 2:在 should 之前用了不重试的命令// type 不会重试,如果 input 还没 ready 就会失败cy.get('#search').type('关键词');cy.get('#search').should('have.value', '关键词');改成确保元素可交互:cy.get('#search').should('be.visible').and('not.be.disabled').type('关键词');坑 3:多个异步操作没有全部等待// 页面发了 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 可能还没回来应该等待所有请求:cy.wait(['@profile', '@orders', '@settings']);坑 4:should 断言了不该断言的内容// 不好:断言太多,分不清是哪个失败cy.get('#card') .should('be.visible') .and('have.class', 'loaded') .and('contain', '数据') .and('not.have.class', 'error');拆开写,失败信息更清晰: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');完整实战示例下面是一个典型的动态页面测试场景,综合运用以上所有策略: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 变化);用断言驱动重试,而不是硬编码等待时间。这样写出来的测试既快又稳,不会因为网络波动或动画时序而随机失败。
服务端阅读 05月27日 23:01

Cypress 中怎么做表单测试?

表单测试要测什么表单是用户和系统交互的主要入口,测试不到位直接影响业务。一个注册表单如果邮箱校验没测到,上线后用户可能注册失败;一个支付表单如果金额边界没覆盖,可能导致资金问题。Cypress 做表单测试,核心就三件事:定位元素、模拟输入、验证结果。但实际写起来,动态渲染、异步校验、跨域接口这些坑一个接一个。下面按实际开发流程一步步来。环境准备安装和启动没什么特别的:npm install cypress --save-devnpx cypress open注意一点:本地测试环境和生产环境的表单行为可能不同,尤其是验证逻辑和接口响应。测试数据尽量用 fixture 管理,不要硬编码在用例里。定位表单元素元素定位是表单测试的第一步,也是最容易出问题的一步。选择器写得不好,页面一改测试就挂。优先用 data-testidcy.get('[data-testid="username-input"]') .type('testuser');data-testid 是最稳定的定位方式,不受样式和 DOM 结构变化影响。CSS 选择器能不用就不用// 这种写法脆弱,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 结构 基本没问题。输入和校验基本输入cy.get('[data-testid="email-input"]').type('test@example.com');cy.get('[data-testid="email-input"]').should('have.value', 'test@example.com');实时校验的验证很多表单有实时校验,比如邮箱格式输入过程中就提示错误:cy.get('[data-testid="email-input"]').type('invalid-email');cy.contains('请输入有效的邮箱地址').should('be.visible');这里用 cy.contains() 比用 cy.get() 找错误提示更可靠,因为错误提示的 DOM 结构可能变化,但文本内容相对稳定。密码字段的处理cy.get('[type="password"]').type('MyPassword123!');密码字段不要用 should('have.value') 去断言内容,因为有些浏览器安全策略会干扰。断言 should('have.prop', 'type', 'password') 确认类型就够了。下拉框和单选框// 下拉框选择cy.get('select#city').select('北京');// 单选框cy.get('[type="radio"]').check('option1');// 复选框cy.get('[type="checkbox"]').check();清除输入cy.get('[data-testid="username-input"]').clear();注意 clear() 在某些自定义输入框上可能不生效,这时可以试 type('{selectall}{backspace}') 代替。表单提交和异步处理直接提交cy.get('button[type="submit"]').click();cy.url().should('include', '/success');用 intercept 拦截接口这是面试高频考点。表单提交通常会调接口,测试不应该依赖真实后端:cy.intercept('POST', '/api/register').as('register');cy.get('button[type="submit"]').click();cy.wait('@register').its('response.statusCode').should('eq', 200);模拟接口返回不止拦截,还可以模拟后端返回不同场景:// 模拟注册成功cy.intercept('POST', '/api/register', { statusCode: 200, body: { message: '注册成功' }});// 模拟邮箱已存在cy.intercept('POST', '/api/register', { statusCode: 409, body: { error: '邮箱已被注册' }});这种能力让测试可以覆盖各种边界场景,不依赖后端状态。边界场景测试面试里最加分的就是边界场景,只测正常流程的测试用例没什么含金量。空值提交cy.get('button[type="submit"]').click();cy.contains('必填字段不能为空').should('be.visible');超长输入const longText = 'a'.repeat(300);cy.get('[data-testid="username-input"]').type(longText);// 验证是否有长度限制提示cy.contains('不能超过').should('be.visible');特殊字符cy.get('[data-testid="username-input"]').type('<script>alert(1)</script>');// 确认 XSS 被正确处理文件上传cy.get('[type="file"]').attachFile({ filePath: 'test.pdf' });cy.get('.upload-success').should('be.visible');文件上传需要安装 cypress-file-upload 插件。用 fixture 管理测试数据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 自带重试机制,但有时候还是不够:// 不推荐:硬等cy.wait(3000);// 推荐:断言驱动等待cy.get('[data-testid="form"]').should('be.visible');cy.get('[data-testid="password-input"]').type('password123');原则:能用断言等待就不要用 cy.wait(时间)。跨域接口问题Cypress 对跨域请求有限制,但测试中又经常需要调不同域的接口:cy.intercept('POST', 'https://api.other-domain.com/login', { body: { token: 'valid' }}).as('login');用 intercept 拦截跨域请求并模拟返回,绕过跨域问题。日期选择器测试困难很多日期选择器用了自定义渲染,原生 type() 打不进去:// 方案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() 不起作用:// 替代方案cy.get('[data-testid="search-input"]').type('{selectall}{backspace}');自定义命令封装复用逻辑如果多个用例都要填同一个表单,封装成自定义命令:// cypress/support/commands.jsCypress.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 准备数据describe('注册表单测试', () => { beforeEach(() => { cy.visit('/register'); }); it('正常注册', () => { /* ... */ }); it('邮箱为空提示错误', () => { /* ... */ }); it('密码强度不足提示错误', () => { /* ... */ });});每个 it 块保持独立,不依赖其他用例的执行结果。并行执行npx cypress run --parallel并行执行要注意:测试用例之间不能有状态依赖,否则并行时会出现随机失败。面试高频问题速查Cypress 做表单测试的核心步骤? 定位元素、模拟输入、提交表单、验证结果。为什么优先用 data-testid? 稳定,不受样式和 DOM 结构变化影响。cy.intercept 和 cy.route 的区别? cy.route 是旧 API,Cypress 6+ 已废弃;cy.intercept 支持拦截和修改请求/响应,功能更强大。怎么测试异步校验? 用 cy.intercept 拦截校验接口,cy.wait 等待响应,再断言 UI 反馈。表单测试怎么处理跨域? 用 cy.intercept 模拟返回绕过跨域,或者在 cypress.config.js 配置 e2e.experimentalOriginDependencies。Cypress 的自动重试机制和手动 wait 怎么选? 优先用断言驱动等待(should),只在断言无法覆盖的场景(如动画)才用 cy.wait()。如何测试文件上传? 安装 cypress-file-upload 插件,使用 attachFile() 方法。自定义输入框 clear() 不生效怎么办? 用 type('{selectall}{backspace}') 替代。怎么管理多套测试数据? 用 cy.fixture() 加载 JSON 文件,不同场景用不同 fixture。表单测试常见的边界场景有哪些? 空值提交、超长输入、特殊字符/XSS、并发提交、网络超时。
服务端阅读 05月27日 23:00

Cypress 数据驱动测试怎么实现?从 fixture 到实战的完整方案

Cypress 的数据驱动测试能让你用同一套测试逻辑跑多组数据,避免为每种输入单独写用例。比如测试登录,与其写 5 个几乎相同的 it 块分别测试不同账号,不如把账号数据抽到 fixtures 文件,用一个循环搞定。本文从 cy.fixture() 基础用法讲起,覆盖 .each() 遍历、动态数据源、常见踩坑和最佳实践。用 cy.fixture() 加载测试数据fixture 文件怎么写Cypress 的 fixtures 目录默认在 cypress/fixtures/,数据格式用 JSON。创建一个登录用的测试数据文件:// cypress/fixtures/users.json[ { "username": "admin", "password": "admin123", "expectSuccess": true }, { "username": "guest", "password": "wrong", "expectSuccess": false }, { "username": "locked_user", "password": "pass123", "expectSuccess": false }]每个数据项里除了输入值,还加了期望结果的字段。这样正负用例都能覆盖,数据本身就表达了测试意图。在测试中加载 fixturecy.fixture() 加载 fixtures 目录下的 JSON 文件,返回解析后的数据。最基础的写法: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() 是一个命令,每条数据生成独立的命令序列,失败行为更可控: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 层面更合适: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() 从接口拿数据: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 里状态没清理在循环里跑登录测试,上一条数据的输入残留在页面上,导致下一条数据输入错乱。解决方法是在每轮循环开始时清理字段: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 块——按这个顺序推进,基本不会踩大坑。
服务端阅读 05月27日 22:58

Cypress 中怎么处理文件上传和下载?selectFile 和下载验证全流程

文件上传和下载是 Web 应用里的高频操作,但在 E2E 测试中常常被忽略或处理不当——要么上传后断言失败,要么下载文件根本找不到。Cypress 从 9.3 版本开始原生支持 selectFile(),下载验证也有成熟的套路。下面把上传和下载分开讲,每个环节都给出可运行的代码。上传文件selectFile 基本用法cy.selectFile() 是 Cypress 9.3+ 引入的原生命令,替代了之前广泛使用的 cypress-file-upload 插件。它直接操作 <input type="file"> 元素,支持单文件、多文件和拖拽模式。单文件上传:cy.get('input[type="file"]').selectFile('cypress/fixtures/report.pdf');多文件上传:cy.get('input[type="file"]').selectFile([ 'cypress/fixtures/image1.png', 'cypress/fixtures/image2.png']);拖拽上传(模拟用户拖文件到页面区域):cy.get('.drop-zone').selectFile('cypress/fixtures/data.csv', { action: 'drag-drop'});路径是相对于项目根目录的,测试文件放在 cypress/fixtures/ 下最规范。selectFile 选项详解selectFile 支持几个常用选项:contents:直接传入文件内容,不需要物理文件。可以用 Cypress.Buffer.from() 构造:cy.get('input[type="file"]').selectFile({ contents: Cypress.Buffer.from('name,value\nfoo,bar'), fileName: 'data.csv', mimeType: 'text/csv'});fileName:指定文件名,服务端可能校验文件扩展名时有用。mimeType:覆盖 MIME 类型,默认根据扩展名自动推断。force:当 input 被隐藏或不可见时,设为 true 强制操作。action:'select'(默认)或 'drag-drop'。lastModified:模拟文件的最后修改时间。大文件和异步上传处理上传大文件时,前端通常会显示进度条,测试需要等待上传完成再做断言。正确做法是拦截上传请求并等待响应:// 先拦截上传接口cy.intercept('POST', '/api/upload').as('upload');// 执行上传cy.get('input[type="file"]').selectFile('cypress/fixtures/large-video.mp4');// 等待上传接口返回成功cy.wait('@upload').its('response.statusCode').should('eq', 200);// 验证 UI 状态cy.get('.upload-status').should('contain', '上传成功');不要用 cy.wait(5000) 这种硬编码等待。网络请求的耗时在不同环境下差异很大,硬等既慢又不稳定。常见上传问题问题 1:文件上传后页面没反应通常是因为 input 元素没有正确触发 change 事件。确认 selectFile 操作的确实是 <input type="file">,而不是包裹它的 div 或 button。如果 input 被隐藏(很多 UI 库会隐藏原生 input),加 { force: true }:cy.get('input[type="file"]').selectFile('test.pdf', { force: true });问题 2:从 cypress-file-upload 插件迁移旧代码用 attachFile(),迁移只需改成 selectFile(),参数格式略有不同:// 旧写法(cypress-file-upload 插件)cy.get('input').attachFile('test.pdf');// 新写法(Cypress 原生)cy.get('input').selectFile('cypress/fixtures/test.pdf');主要区别:路径要写完整相对路径,不再省略 cypress/fixtures/ 前缀。问题 3:上传被 CSP 阻止如果应用有严格的 Content-Security-Policy,Cypress 运行在 iframe 中可能受影响。在 cypress.config.js 中配置 chromeWebSecurity: false 可以绕过:module.exports = { e2e: { chromeWebSecurity: false }};生产环境不要关闭 CSP,这只是测试环境的权宜之计。下载文件Cypress 下载机制Cypress 在测试运行时会把下载的文件保存到 cypress/downloads/ 目录(可在 cypress.config.js 中通过 downloadsFolder 修改)。验证下载的核心思路:触发下载 -> 读取文件 -> 断言内容。基础下载验证// 触发下载cy.get('.download-btn').click();// 读取下载目录中的文件cy.readFile('cypress/downloads/report.csv').should('exist');// 验证文件内容cy.readFile('cypress/downloads/report.csv').should('contain', 'Header1,Header2');通过拦截请求验证下载更可靠的方式是拦截下载请求,确认服务端返回了正确的响应头和内容:cy.intercept('GET', '/api/export', (req) => { req.reply((res) => { expect(res.headers['content-disposition']).to.include('attachment'); expect(res.headers['content-type']).to.eq('application/pdf'); });}).as('download');cy.get('.export-btn').click();cy.wait('@download');这种方式不依赖文件系统,执行更快,也不会因为磁盘写入延迟导致断言失败。二进制文件验证下载 PDF、图片等二进制文件时,用 base64 编码读取并验证文件头:cy.get('.download-pdf-btn').click();// PDF 文件以 %PDF 开头cy.readFile('cypress/downloads/contract.pdf', 'base64') .should('startWith', 'JVBERi0'); // base64 编码的 %PDF等待下载完成文件写入磁盘是异步的,cy.readFile() 会在文件出现后自动重试,但如果文件很大,可能需要增加超时:cy.readFile('cypress/downloads/large-export.zip', null, { timeout: 15000}).should('exist');也可以结合 UI 状态判断:cy.get('.download-btn').click();cy.get('.progress-bar').should('not.exist'); // 等进度条消失cy.readFile('cypress/downloads/data.xlsx').should('exist');配置下载目录在 cypress.config.js 中可以自定义下载路径:const { defineConfig } = require('cypress');module.exports = defineConfig({ e2e: { downloadsFolder: 'cypress/downloads', setupNodeEvents(on, config) { // 每次测试前清空下载目录,避免旧文件干扰 on('before:spec', () => { fs.rmSync(config.downloadsFolder, { recursive: true, force: true }); fs.mkdirSync(config.downloadsFolder, { recursive: true }); }); } }});每次测试前清空下载目录是好习惯,否则上一次测试下载的文件可能干扰本次断言。拖拽上传的特殊处理有些上传组件不是 <input type="file">,而是一个拖放区域(drop zone),用户把文件拖进去触发上传。selectFile 的 drag-drop action 可以模拟这个行为:cy.get('.drop-zone').selectFile('cypress/fixtures/image.png', { action: 'drag-drop'});如果拖放区域同时接受多个文件,传数组即可:cy.get('.drop-zone').selectFile( ['cypress/fixtures/a.png', 'cypress/fixtures/b.png'], { action: 'drag-drop' });完整示例:上传后下载的端到端测试下面是一个真实场景:上传 CSV 文件,服务端处理后返回处理结果,用户下载处理后的文件。describe('CSV 导入导出', () => { beforeEach(() => { cy.intercept('POST', '/api/import').as('import'); cy.intercept('GET', '/api/export*').as('export'); }); it('上传 CSV 后下载处理结果', () => { cy.visit('/data-manager'); // 1. 上传文件 cy.get('input[type="file"]').selectFile('cypress/fixtures/input.csv'); cy.wait('@import').its('response.statusCode').should('eq', 200); cy.get('.import-result').should('contain', '导入 100 条数据'); // 2. 触发导出 cy.get('.export-btn').click(); cy.wait('@export').its('response.statusCode').should('eq', 200); // 3. 验证下载文件 cy.readFile('cypress/downloads/output.csv') .should('contain', '已处理'); });});CI 环境注意事项在 CI 环境中跑文件下载测试时,有几个坑需要注意:无头模式下载目录:cypress run(无头模式)和 cypress open(有头模式)使用相同的下载目录配置,但 CI 中没有图形界面,某些依赖浏览器原生下载对话框的组件可能行为不同。并行测试文件冲突:如果用 --parallel 并行跑测试,多个 runner 会共享同一个下载目录,文件名冲突会导致断言错误。解决方案是在 setupNodeEvents 中为每个 runner 设置独立的下载目录。Docker 容器权限:确保 Docker 容器中 Cypress 进程对下载目录有写入权限。// cypress.config.js - 并行测试隔离下载目录setupNodeEvents(on, config) { const parallelIndex = config.parallelIndex ?? 0; config.downloadsFolder = `cypress/downloads/worker-${parallelIndex}`; return config;}掌握 selectFile() 和 cy.readFile() 这两个核心命令,再配合 cy.intercept() 拦截请求,Cypress 中的文件上传下载测试就能覆盖绝大部分场景。关键原则:用拦截请求代替硬编码等待,用 cy.readFile() 的重试机制代替手动轮询,测试前清空下载目录保持环境干净。
服务端阅读 03月6日 21:40

Cypress 的插件系统如何使用?

Cypress 是一个广泛使用的前端端到端测试框架,以其快速执行、直观的 UI 和强大的测试能力而著称。其核心优势之一在于灵活的插件系统,允许开发者通过扩展功能来定制测试流程,解决特定场景下的挑战。本文将深入解析 Cypress 插件系统的使用方法,结合实战案例和最佳实践,帮助您高效利用这一工具提升测试效率。插件系统概述Cypress 插件系统基于 Node.js,允许在测试运行时注入自定义逻辑。插件通过 cypress/plugins/index.js 文件注册,该文件是测试执行的入口点,负责初始化和管理插件生命周期。插件分为两类:官方插件(如 cypress-plugin-screenshot)和自定义插件(由开发者编写)。核心机制包括:事件钩子:通过 on 对象绑定事件,如 before:run、after:run。模块导出:插件必须导出函数,接收 on 和 config 参数。依赖管理:插件需通过 package.json 声明依赖,确保测试环境一致性。插件系统的优势在于:非侵入式扩展——无需修改测试代码即可添加功能;生态集成——无缝对接 Cypress 的测试流程;社区支持——丰富的插件库覆盖常见场景(如截图、日志、报告生成)。安装和配置插件1. 安装官方插件Cypress 插件通过 npm 或 yarn 安装,建议使用 cypress 命令行工具验证兼容性。# 安装截图插件(示例)npm install cypress-plugin-screenshot安装后,Cypress 会自动识别插件并加载。若需配置,修改 cypress.config.js:// cypress.config.jsmodule.exports = defineConfig({ screenshotOnRun: false, screenshotPath: 'cypress/screenshots',});2. 创建自定义插件自定义插件需在项目根目录下创建 cypress/plugins/index.js 文件。步骤如下:步骤 1:定义插件函数,绑定事件钩子。步骤 2:使用 on 对象注册逻辑,例如处理测试前/后操作。步骤 3:通过 config 参数访问测试配置。代码示例:// cypress/plugins/index.jsmodule.exports = (on, config) => { // 注册自定义钩子 on('before:run', () => { console.log('🚀 测试开始前执行初始化'); // 自定义逻辑,如启动服务 // Example: startServer(); }); // 注册测试后钩子 on('after:run', () => { console.log('✨ 测试结束后清理资源'); // 自定义逻辑,如关闭服务 // Example: stopServer(); }); // 保持配置不变 return config;};关键点:事件顺序:钩子按 before:run → after:run 顺序触发,确保逻辑执行顺序。错误处理:插件中应包含 try-catch 以避免测试中断。路径配置:若插件需访问文件系统,确保 config 中的 paths 正确设置。使用插件的实战案例1. 集成截图插件截图插件 cypress-plugin-screenshot 用于生成测试截图,便于问题排查。安装:npm install cypress-plugin-screenshot。配置:在 cypress.config.js 中启用:module.exports = defineConfig({ screenshotOnRun: true, screenshotOnly: false,});使用:在测试用例中调用:it('验证登录页面', () => { cy.visit('/login'); cy.get('input[name="username"]').type('admin'); cy.get('input[name="password"]').type('secret'); cy.get('button[type="submit"]').click(); // 捕获截图 cy.screenshot('login-success');}); 注意:默认截图存储在 cypress/screenshots 目录,可自定义路径避免冲突。2. 自定义插件:添加测试报告创建插件 cypress-plugin-report 生成 HTML 报告:创建插件:在 cypress/plugins/index.js 中:module.exports = (on, config) => { on('after:each', (result) => { // 生成报告 if (result.status === 'failed') { console.log(`❌ 测试失败: ${result.testName}`); // 调用外部工具生成报告 // Example: generateReport(result); } }); return config;};集成测试:在测试用例中验证:it('验证页面加载', () => { cy.visit('/home'); expect(cy.get('h1').text()).to.equal('Welcome');});实践建议:测试前验证:在 before:run 钩子中检查测试环境(如端口可用性)。性能优化:避免在 before:run 中执行耗时操作,影响测试启动速度。安全提示:插件代码应避免敏感操作,如直接访问用户数据。常见问题与最佳实践1. 插件冲突处理多个插件可能竞争事件钩子。解决方案:优先级设置:通过 config 参数调整钩子顺序。模块隔离:为不同插件创建独立模块,避免全局污染。2. 性能考量最小化插件:仅安装必需插件,减少测试启动时间(Cypress 建议 \< 100ms)。懒加载:对于非核心插件,使用 on('before:run', () => { ... }) 条件加载。3. 调试技巧日志输出:在插件中使用 console.log 追踪执行流程。调试工具:结合 cypress open 启动调试器,验证插件行为。结论Cypress 插件系统是提升测试灵活性和效率的关键工具。通过正确安装、配置和使用插件,您可以解决复杂场景(如截图、报告生成、服务集成),并显著减少手动维护成本。建议:优先使用官方插件:确保稳定性和社区支持。文档驱动:阅读插件仓库的 README.md 了解详细用法。渐进式扩展:从简单插件开始,逐步构建自定义解决方案。记住,插件系统不是万能药——始终优先确保核心测试逻辑简洁可靠。Cypress 3.0+ 版本进一步优化了插件链,建议升级到最新稳定版以获取最佳体验。通过本文的实践指导,您将能高效驾驭 Cypress 插件生态,打造更强大的测试流程。 参考资料:​
前端阅读 1082024年7月10日 00:18

什么是“条件测试”和“数据驱动测试”?如何在Cypress中实现它们?

什么是“条件测试”和“数据驱动测试”?条件测试(Conditional Testing)是指根据特定条件或参数的不同,执行不同的测试路径。这种测试可以帮助确保软件在多种环境和条件下的表现符合预期。例如,在进行Web应用的测试时,可能需要检查不同的用户角色(如管理员、普通用户)对应的界面和功能是否正确。数据驱动测试(Data-driven Testing)是一种测试方法,它将测试脚本与外部数据源分离,并通过遍历数据源中的数据执行测试。这种方法可以增加测试的灵活性和覆盖率,减少代码重复,使测试更容易维护。测试数据可以存储在多种形式,如数据库、Excel表格、CSV文件或JSON文件等。如何在Cypress中实现它们?实现条件测试:在Cypress中,可以通过使用条件语句(例如 if-else)来实现条件测试。这允许根据应用的状态或响应来改变测试流程。例如,假设你需要测试一个具有不同用户角色的登录功能,可以根据用户角色的不同执行不同的断言:describe('条件测试示例', () => { it('应根据用户角色测试不同的功能', () => { cy.visit('/login'); cy.get('input[name="username"]').type('username'); cy.get('input[name="password"]').type('password'); cy.get('form').submit(); cy.get('body').then($body => { if ($body.text().includes('管理员')) { // 对管理员用户进行的测试 cy.get('.admin-panel').should('be.visible'); } else { // 对普通用户进行的测试 cy.get('.user-panel').should('be.visible'); } }); });});实现数据驱动测试:在Cypress中,可以通过从外部文件读取数据来实现数据驱动的测试。常见的做法是使用Cypress的fixtures功能来加载测试数据。例如,假设有一个JSON文件users.json存储了多个用户的登录信息,可以创建一个测试用例遍历所有用户:describe('数据驱动测试示例', () => { beforeEach(() => { cy.fixture('users').then((users) => { this.users = users; }); }); it('应为每个用户测试登录功能', function() { this.users.forEach((user) => { cy.visit('/login'); cy.get('input[name="username"]').type(user.username); cy.get('input[name="password"]').type(user.password); cy.get('form').submit(); cy.url().should('include', '/dashboard'); }); });});在这个示例中,users.json可能看起来像这样:[ { "username": "user1", "password": "pass1" }, { "username": "user2", "password": "pass2" }]通过这种方式,可以轻松地为不同的用户数据执行相同的测试逻辑,增强测试的灵活性和覆盖率。
前端阅读 892024年7月10日 00:18

如何使用Cypress测试涉及第三方集成的场景,如支付网关或社交媒体API?

解答:在进行自动化测试时,涉及第三方集成,如支付网关或社交媒体API,是一个常见的挑战。使用Cypress进行这类测试时,主要的考虑是如何准确模拟第三方服务的交互,确保我们的应用在实际使用中能够正确地与这些服务进行交互。以下是我使用Cypress测试第三方集成的具体策略和步骤:1. 使用Cypress的网络请求拦截功能(Stubbing and Interception)Cypress提供了强大的网络请求拦截功能,允许我们模拟第三方API的响应。这是确保测试的可重复性和稳定性的关键。示例:假设我们的应用使用了一个支付网关的API来处理付款。在Cypress中,我们可以使用cy.intercept()功能来拦截应用对支付API的调用,并提供预设的响应。cy.intercept('POST', '/payments', { statusCode: 200, body: { status: 'success', transactionId: '12345' },}).as('paymentProcess');这样,无论何时应用尝试发起支付,Cypress都会返回一个成功的支付响应,无需实际与支付服务进行交互。2. 环境配置与变量使用使用Cypress的环境变量来管理不同第三方服务的配置信息,如API密钥、基础URL等,这样有助于在不同环境之间切换和维护。示例:我们可以在cypress.json配置文件中设置环境变量,或者使用Cypress.env()方法动态获取。{ "env": { "paymentAPIBaseUrl": "https://api.paymentgateway.com" }}cy.intercept('POST', Cypress.env('paymentAPIBaseUrl') + '/payments', { ... });3. 模拟用户行为在测试涉及用户与第三方服务交互的场景时,非常重要的一点是要模拟真实的用户行为。示例:如果是社交媒体集成,比如用户需要登录社交媒体账号并分享内容,我们可以通过Cypress模拟整个登录和分享的过程。cy.get('input[name="username"]').type('testuser');cy.get('input[name="password"]').type('password123');cy.get('button[type="submit"]').click();cy.get('button.share').click();4. 使用第三方Mock服务或自建Mock服务器对于复杂的第三方服务交互,有时使用Cypress内建的拦截功能可能不足以模拟所有的细节。这时可以考虑使用第三方的Mock服务工具,如Mockoon,或者自建一个Mock服务器来更全面地模拟第三方服务。5. 持续集成与部署在CI/CD流程中集成Cypress测试,确保每次代码更新都能自动运行这些测试。这有助于及早发现与第三方集成相关的问题。示例:在GitHub Actions中设置Cypress测试步骤:- name: Run Cypress tests uses: cypress-io/github-action@v2 with: start: npm start wait-on: 'http://localhost:3000'总结:测试涉及第三方集成的场景要求我们既要考虑测试的覆盖面和真实性,也要确保测试的可维护性和稳定性。通过以上策略,我们能够有效地利用Cypress来测试这些关键的集成点,确保应用的整体质量和用户体验。
前端阅读 1072024年7月10日 00:17

如何处理Cypress测试中涉及弹出式拦截程序和通知的场景?

在处理Cypress测试中涉及弹出式拦截程序(如警告、确认对话框)和通知的场景时,我们可以采用一些策略来确保这些弹窗不会影响自动化测试的执行。以下是一些具体的方法和例子:1. 处理JavaScript弹窗(Alerts、Confirms)Cypress提供了简单的API来处理JavaScript的alert和confirm弹窗。使用cy.on()函数可以捕捉到这些事件并根据需要进行处理。例子:假设有一个按钮点击后会触发一个确认框,我们可以这样写测试代码:// 拦截确认框,并自动点击确定cy.on('window:confirm', (str) => { expect(str).to.equal('您确定要执行此操作吗?') return true})cy.get('button#confirm-btn').click()// 接下来的代码是确认操作后的逻辑在这个例子中,当点击按钮后,会自动处理确认框并继续执行后续的测试代码。2. 处理自定义弹窗对于非标准的JavaScript弹窗,比如由HTML/CSS实现的模态对话框,我们通常需要定位弹窗的元素并操作它们。例子:假设一个登录按钮点击后弹出一个自定义的登录框:cy.get('button#login-btn').click()cy.get('.modal').should('be.visible')cy.get('.modal #username').type('user')cy.get('.modal #password').type('password')cy.get('.modal button#submit').click()这段代码首先触发登录框的出现,然后填入用户名和密码,最后点击提交按钮。3. 处理浏览器通知对于浏览器的通知,Cypress不能直接操作这些元素,因为它们是由浏览器控制而不是由页面控制。不过,我们可以通过修改浏览器的配置来自动拒绝或接受通知。例子:// 在Cypress的配置文件(cypress.json)中添加:{ "chromeWebSecurity": false, "permissions": { "notifications": "deny" }}这样配置后,所有的通知都会被自动拒绝,从而不会影响到测试的执行。总结在Cypress中处理弹出式拦截程序和通知,关键是区分是标准的JavaScript弹窗还是自定义弹窗,以及是否需要特别处理浏览器级别的通知。通过以上的策略和方法,我们可以有效地控制和测试这些场景,确保自动化测试的顺利进行。
前端阅读 932024年7月9日 23:56

Cypress 如何测试数据异步更改的场景,如聊天应用程序中的实时更新?

1. 理解问题的核心首先,测试数据的异步更改意味着我们需要验证的是数据在不同的时间点的状态。在实时聊天应用中,比如一个用户发送消息后,另一个用户应该能看到更新的消息。2. 使用Cypress的实时数据测试方法a. 使用Cypress命令和断言Cypress提供了一套丰富的API来处理异步操作,如 cy.wait()、cy.get()等,这些可以用来捕捉异步更新后的UI变化:Setup:首先需要确保聊天应用的前后端已经启动且可访问。监听数据变化:使用 cy.intercept()来监听网络请求,这对于捕捉发送和接收消息的请求非常有用。例如: cy.intercept('POST', '/messages').as('postMessage'); cy.intercept('GET', '/messages').as('getMessages');模拟发送消息:通过UI或API发送消息,并等待网络请求完成: cy.get('[data-cy=message-input]').type('Hello, World!'); cy.get('[data-cy=send-button]').click(); cy.wait('@postMessage');验证接收端的UI更新:检查是否在另一个用户的聊天界面中接收到了消息: cy.wait('@getMessages'); cy.get('[data-cy=message-list]').should('contain', 'Hello, World!');b. 时间旅行和快照功能Cypress的时间旅行功能可以让我们查看每一次操作后应用的状态,这对于理解应用如何响应某个操作很有帮助。同时,Cypress的快照功能可以在测试过程中捕捉UI的变化,帮助我们验证UI在数据变化时的响应。3. 实例演示假设我们有一个聊天应用,用户A和用户B都在聊天界面。用户A发送了一条消息,我们需要测试用户B是否能实时接收到这条消息。测试脚本: describe('Real-time Chat Update', () => { it('displays messages sent by other users in real time', () => { cy.visit('http://localhost:3000/userA'); cy.get('[data-cy=message-input]').type('Hello, User B!'); cy.get('[data-cy=send-button]').click(); cy.visit('http://localhost:3000/userB'); cy.contains('Hello, User B!'); }); });4. 结论通过使用Cypress的网络拦截、断言以及UI检查功能,我们可以有效地模拟和测试聊天应用中的实时数据更新。这种测试对于确保用户体验的连贯性和实时性至关重要。