服务端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` 保证每个用例从相同初始状态运行,测试更稳定。标签
Cypress
Cypress 是一个前端自动化测试工具,用于测试基于Web的应用程序。它能够测试运行在浏览器中的应用,并且适用于单元测试、集成测试和端到端(E2E)测试。Cypress 提供了一个丰富的API集,以及一个友好的交互式界面,让开发和测试人员能够轻松编写、运行和调试测试用例。

服务端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 块——按这个顺序推进,基本不会踩大坑。