什么是端到端测试 (E2E Testing)?
端到端测试是一种软件测试方法,用于验证整个应用程序的流程是否从用户的角度按预期工作。它模拟真实的用户场景,从用户界面 (UI) 开始,通过应用程序的各个层面(包括与其他系统、数据库、网络等的交互),直到最终输出或结果。
与单元测试/集成测试的区别:
- 单元测试 (Unit Testing) :测试最小的可测试单元(如函数、类、组件的独立部分),关注代码逻辑的正确性,通常是隔离的。
- 集成测试 (Integration Testing) :测试多个单元或模块组合在一起时的交互是否正确。例如,测试前端组件与其依赖的服务模块的交互。
- 端到端测试 (End-to-End Testing) :测试整个应用程序的完整流程,从用户的视角出发,覆盖从 UI 到后端的完整链路。
可以把它们想象成测试一辆汽车:
- 单元测试:测试引擎的某个零件(火花塞)是否工作正常。
- 集成测试:测试引擎组装完成后是否能正常启动并提供动力。
- 端到端测试:测试驾驶员能否启动汽车,挂挡,踩油门,刹车,并顺利从 A 点开到 B 点。
为什么需要端到端测试?
- 模拟真实用户场景:E2E 测试最接近用户实际使用应用的方式,能发现单元测试和集成测试可能遗漏的问题。
- 验证系统集成:确保应用程序的各个部分(前端、后端、数据库、第三方服务等)能够正确协同工作。
- 提高信心:通过覆盖关键用户流程,E2E 测试为应用程序的整体质量和稳定性提供了高度的信心,尤其是在部署新版本之前。
- 减少回归风险:随着应用的迭代,E2E 测试可以帮助捕捉由于代码更改而引入的回归错误。
- 文档作用:良好的 E2E 测试用例本身也可以作为应用程序功能和用户流程的活文档。
端到端测试的挑战
- 速度慢:E2E 测试需要启动浏览器,加载页面,模拟用户操作,这通常比单元测试和集成测试慢得多。
- 不稳定 (Flaky Tests) :由于依赖外部环境(网络、服务器状态)、异步操作、UI 渲染时序等因素,E2E 测试有时会无缘无故地失败,增加了维护的复杂性。
- 维护成本高:UI 的变化(即使很小)可能导致大量 E2E 测试用例失效,需要频繁更新。
- 环境依赖:需要一个完整的、可运行的测试环境,包括前端、后端和可能的数据库。
- 调试困难:当 E2E 测试失败时,定位问题的根本原因可能比较复杂,因为它涉及到整个应用栈。
主流 E2E 测试框架简介
有许多优秀的前端 E2E 测试框架可供选择:
-
Cypress:
- 优点: 一体化测试运行器和仪表盘,对开发者友好,调试体验佳(时间旅行、自动截图/录屏),自动等待,网络请求控制强大,文档完善,社区活跃。直接在浏览器中运行测试,与应用在同一事件循环中。
- 缺点: 仅支持 JavaScript/TypeScript 编写测试,官方主要支持 Chrome 系浏览器和 Firefox(对 Safari 的支持仍在实验阶段),不支持同时控制多个浏览器标签页或窗口(有变通方法但非原生支持)。
-
Playwright (Microsoft) :
- 优点: 支持多种语言 (JavaScript, TypeScript, Python, Java, C#),跨浏览器 (Chromium, Firefox, WebKit/Safari),支持并行测试,自动等待,强大的网络拦截,支持移动端模拟,支持多标签页/窗口/iframe。
- 缺点: 相对 Cypress 学习曲线稍陡峭一些,社区生态仍在发展中。
-
Selenium:
- 优点: 最老牌、最成熟的框架,支持几乎所有浏览器和编程语言,拥有庞大的社区和丰富的资源。
- 缺点: API 相对底层,配置和使用起来比较复杂,需要 WebDriver,测试编写和维护成本较高,原生等待机制较弱。
-
Puppeteer (Google) :
- 优点: Google Chrome 团队开发,主要用于控制 Chrome/Chromium (也支持 Firefox),API 丰富,非常适合浏览器自动化任务和爬虫,性能较好。
- 缺点: 主要针对 Chrome,跨浏览器支持有限,更偏向于浏览器自动化库而非完整的测试框架(需要与其他测试运行器如 Jest 结合)。
我们将重点使用 Cypress 进行详细的代码讲解。
Cypress 深度解析与工作流程
我们将围绕一个假设的简单 Web 应用(例如一个任务管理应用或简单的博客)来编写 Cypress 测试。
1. 环境搭建与项目初始化
前提: 你需要安装 Node.js 和 npm (或 yarn)。
步骤:
-
创建项目目录并初始化:
bashmkdir cypress-e2e-demo cd cypress-e2e-demo npm init -y
-
安装 Cypress:
bashnpm install cypress --save-dev
或者使用 yarn:
bashyarn add cypress --dev
-
打开 Cypress Test Runner (首次运行会初始化项目结构) :
bashnpx cypress open
或者,将其添加到
package.json
的scripts
中:json// package.json { "scripts": { "cy:open": "cypress open", "cy:run": "cypress run" } }
然后运行
npm run cy:open
。首次运行
cypress open
时,Cypress 会在你的项目根目录下创建以下结构:scsscypress-e2e-demo/ ├── cypress/ │ ├── fixtures/ # 存放测试数据,如 JSON 文件 │ │ └── example.json │ ├── integration/ # (Cypress 9 及更早版本) 存放测试 spec 文件 (现在是 e2e/) │ ├── e2e/ # (Cypress 10+) 存放测试 spec 文件 │ │ └── (示例 spec 文件) │ ├── plugins/ # (Cypress 9 及更早版本) 存放插件 (现在集成到配置文件) │ ├── support/ # 存放自定义命令和全局配置 │ │ ├── commands.js # 注册自定义命令 │ │ ├── e2e.js # (Cypress 10+) E2E 测试的全局 setup │ │ └── index.js # (Cypress 9 及更早版本) 全局 setup │ └── (其他文件夹如 downloads, screenshots, videos) ├── cypress.config.js # (Cypress 10+) Cypress 配置文件 ├── node_modules/ └── package.json
Cypress Test Runner 会打开一个图形界面,允许你选择浏览器、查看和运行测试文件。
2. 第一个 Cypress 测试
在 cypress/e2e/
目录下创建一个新的测试文件,例如 first_test.cy.js
。
(Cypress 约定测试文件以 .cy.js
, .cy.ts
, .spec.js
等结尾)。
js
// cypress/e2e/first_test.cy.js
// describe 用于组织测试套件,可以嵌套
describe('My First Test Suite', () => {
// it 或 test 用于定义单个测试用例
it('Visits the Kitchen Sink and checks title', () => {
// cy 是 Cypress 的全局对象,所有 Cypress 命令都从它开始
// cy.visit() 用于导航到指定的 URL
cy.visit('https://example.cypress.io'); // Cypress 官方提供的示例网站
// cy.title() 获取当前页面的标题
// .should() 是一个断言方法,可以链接多种 Chai.js 断言
cy.title().should('include', 'Cypress.io: Kitchen Sink'); // 断言标题包含特定文本
});
it('Finds an element and interacts with it', () => {
cy.visit('https://example.cypress.io/commands/actions');
// cy.get() 用于根据选择器获取一个或多个 DOM 元素
// .type() 用于在可输入的元素中输入文本
cy.get('.action-email') // 通过 class 选择器获取元素
.type('[email protected]')
.should('have.value', '[email protected]'); // 断言输入框的值
// .click() 用于点击元素
cy.get('.action-btn').click();
// 检查元素是否可见
cy.get('#action-canvas').should('be.visible');
});
});
运行测试 :
在 Cypress Test Runner 中,点击 first_test.cy.js
文件名即可运行。你会看到浏览器自动执行这些操作,并在左侧面板显示命令日志。
3. 元素定位与选择器
Cypress 提供了多种定位元素的方法:
-
cy.get(selector, options?)
: 最常用的命令,使用 CSS 选择器。- ID:
cy.get('#my-id')
- Class:
cy.get('.my-class')
- Attribute:
cy.get('[name="email"]')
,cy.get('[data-testid="submit-button"]')
- Tag:
cy.get('button')
- 组合和层级:
cy.get('form .field input[type="text"]')
- 伪类:
cy.get('li:first-child')
- ID:
-
cy.contains(text, options?)
或cy.contains(selector, text, options?)
: 根据文本内容定位元素。cy.contains('Submit')
// 查找任何包含 "Submit" 文本的元素cy.contains('button', 'Submit')
// 查找包含 "Submit" 文本的 button 元素- 支持正则表达式:
cy.contains(/^Submit$/)
// 精确匹配
最佳实践:使用 data-*
属性进行测试
为了使测试更健壮,不易受样式或文本内容(可能因国际化而改变)变化的影响,推荐使用专门为测试添加的 data-*
属性,如 data-cy
或 data-testid
。
HTML:
html
<button data-cy="login-button">Login</button>
<input type="email" data-testid="email-input" />
Cypress 测试:
js
cy.get('[data-cy="login-button"]').click();
cy.get('[data-testid="email-input"]').type('[email protected]');
4. 交互操作
Cypress 提供了丰富的命令来模拟用户交互:
-
.type(text, options?)
: 输入文本。jscy.get('input[name="username"]').type('john.doe'); cy.get('textarea').type('This is a long message.{enter}With a new line.'); // {enter} 等特殊字符
-
.click(options?)
: 点击元素。jscy.get('button.submit').click(); cy.contains('Next Page').click(); cy.get('div').click('topLeft'); // 点击元素的特定位置 cy.get('button').click({ multiple: true }); // 点击所有匹配的按钮 cy.get('button').click({ force: true }); // 强制点击,即使元素不可见或被覆盖(谨慎使用)
-
.dblclick(options?)
: 双击元素。 -
.rightclick(options?)
: 右键点击元素。 -
.select(valueOrTextOrIndex, options?)
: 选择<select>
下拉框中的选项。jscy.get('select[name="country"]').select('USA'); // 按 value 或文本选择 cy.get('select[name="options"]').select(['option1', 'option3']); // 多选
-
.check(options?)
: 选中复选框或单选按钮。jscy.get('input[type="checkbox"][value="agree"]').check(); cy.get('input[type="radio"][name="gender"]').check('female');
-
.uncheck(options?)
: 取消选中复选框。 -
.clear(options?)
: 清除输入框或文本域的内容。 -
.focus()
,.blur()
: 触发焦点和失焦事件。 -
.trigger(eventName, options?)
: 触发任意 DOM 事件。jscy.get('.menu').trigger('mouseover'); cy.get('.draggable').trigger('mousedown').trigger('mousemove', { clientX: 100, clientY: 200 }).trigger('mouseup');
-
.scrollIntoView(options?)
: 将元素滚动到可视区域。 -
.scrollTo(position, options?)
或.scrollTo(x, y, options?)
: 滚动窗口或可滚动元素。jscy.scrollTo('bottom'); // 滚动到页面底部 cy.get('.scrollable-div').scrollTo(0, 500); // 滚动 div 到垂直位置 500px
5. 断言 (Assertions)
Cypress 内置了 Chai、Chai-jQuery 和 Sinon-Chai 断言库,最常用的是通过 .should()
和 .and()
(与 .should()
等价,用于链式可读性) 进行断言。
js
// cypress/e2e/assertions_test.cy.js
describe('Assertion Examples', () => {
beforeEach(() => {
// 假设我们有一个简单的 HTML 页面用于测试
// content of public/test-page.html
/*
<!DOCTYPE html>
<html>
<head><title>Test Page</title></head>
<body>
<h1 id="main-heading" class="title">Welcome!</h1>
<p class="subtext" style="color: blue;">This is a test page.</p>
<input type="text" id="name-input" value="Cypress" />
<input type="checkbox" id="agree-checkbox" checked disabled />
<button id="submit-btn" class="btn primary">Submit</button>
<div id="message-area" style="display: none;">Success!</div>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</body>
</html>
*/
// 你需要确保这个 public/test-page.html 文件存在,并且 Cypress 可以访问它
// 通常通过配置 baseUrl 或直接访问文件路径 (如果测试服务器已配置)
// 为了简单,我们这里假设 baseUrl 指向一个能提供此页面的服务
// 如果没有服务,可以 cy.visit('public/test-page.html') 如果 cypress.config.js 中配置了 e2e: { supportFile: false }
// 或者直接用 file:/// 协议,但这不推荐用于真实应用测试
// 更好的方式是启动一个简单的 HTTP 服务器服务 public 目录
// 例如,使用 `npx http-server public -p 8081`,然后在测试中 `cy.visit('http://localhost:8081/test-page.html')`
// 这里我们假设 `baseUrl` 已配置为 `http://localhost:8081`
// 并且 `test-page.html` 在 `public` 目录下
cy.visit('/test-page.html'); // 路径相对于 baseUrl
});
it('should verify element properties', () => {
// 存在性
cy.get('#main-heading').should('exist');
cy.get('#non-existent-element').should('not.exist');
// 可见性
cy.get('#main-heading').should('be.visible');
cy.get('#message-area').should('not.be.visible'); // 因为 display: none
// 文本内容
cy.get('#main-heading').should('have.text', 'Welcome!');
cy.get('#main-heading').should('include.text', 'Welcome');
cy.get('.subtext').contains('test page', { matchCase: false }); // cy.contains 也是一种断言
// 值 (通常用于 input, select, textarea)
cy.get('#name-input').should('have.value', 'Cypress');
// Class
cy.get('#main-heading').should('have.class', 'title');
cy.get('#submit-btn').should('not.have.class', 'secondary');
// CSS 属性
cy.get('.subtext').should('have.css', 'color', 'rgb(0, 0, 255)'); // 注意颜色值格式
// 属性 (HTML attributes)
cy.get('#agree-checkbox').should('have.attr', 'type', 'checkbox');
cy.get('#agree-checkbox').should('be.checked');
cy.get('#agree-checkbox').should('be.disabled');
cy.get('#submit-btn').should('not.be.disabled');
// 元素数量
cy.get('ul li').should('have.length', 3);
cy.get('ul li').should('have.length.greaterThan', 2);
cy.get('ul li').should('have.length.lessThan', 5);
// 也可以使用 expect (TDD 风格)
cy.get('#main-heading').then(($el) => {
expect($el.text()).to.equal('Welcome!');
expect($el).to.have.class('title');
});
});
it('should verify URL and title', () => {
cy.url().should('include', '/test-page.html');
cy.title().should('eq', 'Test Page');
});
it('should verify list items', () => {
cy.get('ul li').eq(0).should('have.text', 'Item 1'); // eq(index) 选择特定索引的元素
cy.get('ul li').eq(1).should('have.text', 'Item 2');
cy.get('ul li').last().should('have.text', 'Item 3');
cy.get('ul li').each(($li, index, $list) => {
cy.wrap($li).should('contain.text', `Item ${index + 1}`);
if (index === 0) {
expect($li.text()).to.equal('Item 1');
}
});
});
});
6. 处理异步与等待
Cypress 的一个核心优势是其内置的自动等待和重试机制。
-
自动等待 : 当你执行一个命令(如
cy.get()
,.click()
,.should()
),Cypress 会自动等待元素出现、变为可操作状态或断言通过,直到达到默认的超时时间 (defaultCommandTimeout
, 默认为 4000ms)。csharp// 假设 #dynamic-content 元素会在 2 秒后出现 cy.get('#dynamic-content').should('be.visible'); // Cypress 会自动等待 cy.get('#clickable-button').click(); // Cypress 会等待按钮可点击
-
.should()
的重试: 断言也会自动重试,直到成功或超时。 -
cy.wait(timeInMs)
: 显式等待固定时间(应尽量避免,因为它会使测试变慢且可能不稳定)。scsscy.wait(500); // 等待 500ms (不推荐)
-
cy.wait('@alias')
: 等待一个被别名标记的网络请求完成(非常有用,见网络请求部分)。 -
超时配置:
- 全局:
cypress.config.js
->defaultCommandTimeout
- 单个命令:
cy.get('#slow-element', { timeout: 10000 }).should('be.visible');
- 全局:
7. 测试组织与 Hooks
Cypress 使用 Mocha 的 BDD 语法 (describe
, it
) 和 Hooks。
js
// cypress/e2e/hooks_test.cy.js
describe('Hooks Example Suite', () => {
before(() => {
// 在套件中所有测试用例运行之前执行一次
cy.log('Before all tests in the suite');
// 例如:设置全局测试数据,登录一次(如果状态可跨测试共享)
});
after(() => {
// 在套件中所有测试用例运行之后执行一次
cy.log('After all tests in the suite');
// 例如:清理测试数据
});
beforeEach(() => {
// 在套件中每个测试用例运行之前执行
cy.log('Before each test');
cy.visit('/test-page.html'); // 确保每个测试都从干净的页面开始
});
afterEach(() => {
// 在套件中每个测试用例运行之后执行
cy.log('After each test');
// 例如:截屏、登出
});
it('Test case 1', () => {
cy.log('Running Test Case 1');
cy.get('#main-heading').should('be.visible');
});
it('Test case 2', () => {
cy.log('Running Test Case 2');
cy.get('#submit-btn').should('exist');
});
describe('Nested Suite', () => {
beforeEach(() => {
cy.log('Before each test in NESTED suite'); // 会覆盖外部的 beforeEach 吗?不,都会执行,外部先执行
});
it('Nested Test case A', () => {
cy.log('Running Nested Test Case A');
cy.get('ul li').should('have.length', 3);
});
});
// 跳过测试
it.skip('Skipped test case', () => {
// 此测试不会执行
cy.log('This will not run');
});
// 只运行此测试 (在当前 describe 块内,如果多个 .only,则都运行)
// it.only('Focused test case', () => {
// cy.log('This is the only test that will run in this suite (if uncommented)');
// cy.get('#name-input').type('Focus');
// });
});
```执行顺序: `before` -> `beforeEach` (outer) -> `beforeEach` (inner, if applicable) -> `it` -> `afterEach` (inner, if applicable) -> `afterEach` (outer) -> `after`.
### 8. 网络请求处理 (XHR/Fetch)
`cy.intercept(method?, urlMatcher, routeHandler?)` 是 Cypress 中处理网络请求的核心命令。
```javascript
// cypress/e2e/network_test.cy.js
describe('Network Request Handling', () => {
beforeEach(() => {
cy.visit('https://jsonplaceholder.typicode.com/'); // 访问一个提供公共 API 的网站
});
it('should listen to a GET request and assert its response', () => {
// 拦截 GET 请求到 /posts/1
cy.intercept('GET', '/posts/1').as('getPost1'); // .as() 给拦截器起一个别名
// 触发请求的操作 (例如点击一个按钮,或者直接在应用中加载)
// 这里我们手动通过 cy.request 模拟一个应用发起的请求,或者如果页面加载时就发请求,cy.visit() 就会触发
// 假设页面上有一个按钮会获取 post 1
// cy.get('#fetch-post-1-button').click();
// 为演示,我们直接用 cy.request 触发,实际应用中是应用自身发请求
cy.request('https://jsonplaceholder.typicode.com/posts/1');
// 等待别名为 @getPost1 的请求完成,并获取其响应
cy.wait('@getPost1').then((interception) => {
console.log('Interception object:', interception);
expect(interception.response.statusCode).to.equal(200);
expect(interception.response.body).to.have.property('userId', 1);
expect(interception.response.body).to.have.property('id', 1);
expect(interception.response.body).to.have.property('title');
});
});
it('should stub a GET request with a fixture', () => {
// 准备一个 fixture 文件: cypress/fixtures/post.json
// { "userId": 100, "id": 100, "title": "Mocked Post Title", "body": "This is a mocked post body." }
cy.fixture('post.json').as('mockPostData'); // 加载 fixture 并别名
cy.intercept('GET', '/posts/1', { fixture: 'post.json' }).as('getPostStubbed');
// 或者直接提供对象:
// cy.intercept('GET', '/posts/1', {
// statusCode: 200,
// body: { userId: 100, id: 100, title: "Mocked Post Title", body: "This is a mocked post body." }
// }).as('getPostStubbed');
// 假设应用代码会请求 /posts/1
// 例如,在页面上显示这个 post 的标题
// 为了演示,我们直接访问一个会显示 post 数据的页面(如果存在)或模拟请求
// 假设有个函数 displayPostTitle 会 fetch('/posts/1') 并显示标题
// displayPostTitle();
// 这里我们用 cy.request 来模拟应用发起的请求
cy.request('https://jsonplaceholder.typicode.com/posts/1').then(response => {
// 这里的 response 会是我们 stub 的数据
expect(response.body.title).to.equal('Mocked Post Title');
});
cy.wait('@getPostStubbed'); // 确认 stub 被调用
// 可以在页面上断言显示的是 stubbed data
// cy.get('#post-title-display').should('have.text', 'Mocked Post Title');
});
it('should stub a POST request and verify request body', () => {
const newPost = { title: 'My New Post', body: 'Content of the new post', userId: 1 };
const serverResponse = { ...newPost, id: 101 };
cy.intercept('POST', '/posts', (req) => {
// 可以在这里断言请求体
expect(req.body.title).to.equal(newPost.title);
expect(req.body.userId).to.equal(newPost.userId);
// 回复一个模拟的响应
req.reply({
statusCode: 201,
body: serverResponse,
headers: { 'x-custom-header': 'mocked' },
});
}).as('createPost');
// 模拟应用提交新 post
cy.request({
method: 'POST',
url: 'https://jsonplaceholder.typicode.com/posts',
body: newPost
}).then(response => {
expect(response.status).to.equal(201);
expect(response.body.id).to.equal(101);
expect(response.headers).to.have.property('x-custom-header', 'mocked');
});
cy.wait('@createPost').its('response.statusCode').should('eq', 201);
// cy.get('#new-post-id-display').should('have.text', '101');
});
it('should modify response headers', () => {
cy.intercept('GET', '/users', (req) => {
req.continue((res) => { // 让原始请求继续,然后修改响应
res.headers['x-powered-by'] = 'Cypress Magic';
});
}).as('getUsers');
cy.request('https://jsonplaceholder.typicode.com/users');
cy.wait('@getUsers').then((interception) => {
expect(interception.response.headers).to.have.property('x-powered-by', 'Cypress Magic');
});
});
it('should simulate a network error', () => {
cy.intercept('GET', '/non-existent-resource', {
statusCode: 404,
body: { error: 'Resource not found' },
delayMs: 100, // 模拟延迟
}).as('getError');
// 假设应用会处理这个错误并显示错误信息
// cy.get('#fetch-error-button').click();
cy.request({url: 'https://jsonplaceholder.typicode.com/non-existent-resource', failOnStatusCode: false})
.then(response => {
expect(response.status).to.equal(404);
expect(response.body.error).to.equal('Resource not found');
});
cy.wait('@getError');
// cy.get('.error-message').should('contain.text', 'Resource not found');
});
});
9. Fixtures (测试数据管理)
Fixtures 用于存储静态的测试数据,通常是 JSON 文件,存放在 cypress/fixtures
目录下。
cypress/fixtures/user.json
:
js
{
"username": "testuser",
"email": "[email protected]",
"password": "password123"
}
cypress/fixtures/products.json
:
js
[
{ "id": 1, "name": "Laptop", "price": 1200 },
{ "id": 2, "name": "Mouse", "price": 25 },
{ "id": 3, "name": "Keyboard", "price": 75 }
]
使用 cy.fixture(filePath, options?)
:
js
// cypress/e2e/fixtures_test.cy.js
describe('Using Fixtures', () => {
beforeEach(() => {
// 加载 fixture 数据,可以在 beforeEach 中为所有测试用例准备
cy.fixture('user.json').as('userData'); // 使用 .as() 将数据存储在 this.userData 中 (在 it 块中通过 this.userData 访问)
cy.fixture('products.json').as('productsData');
});
it('should use user data from fixture for login form', function() { // 注意这里用 function() 而不是箭头函数,才能用 this
cy.visit('/login'); // 假设有登录页
// this.userData 现在可用
expect(this.userData.username).to.equal('testuser');
cy.get('[data-cy="username-input"]').type(this.userData.username);
cy.get('[data-cy="password-input"]').type(this.userData.password);
// cy.get('[data-cy="login-button"]').click();
// ... 断言登录成功
});
it('should display products from fixture data', function() {
cy.visit('/products'); // 假设有产品列表页
// 假设页面会根据 API 返回的数据渲染产品列表
// 我们可以拦截 API 请求并使用 fixture 数据
cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts');
cy.wait('@getProducts');
// 断言页面上显示的产品数量和内容
cy.get('.product-item').should('have.length', this.productsData.length);
this.productsData.forEach((product, index) => {
cy.get('.product-item').eq(index).within(() => { // .within() 将后续命令的作用域限制在此元素内
cy.get('.product-name').should('have.text', product.name);
cy.get('.product-price').should('contain.text', product.price);
});
});
});
it('can load fixture directly in test', () => {
cy.fixture('user.json').then((user) => {
// user 对象就是 fixture 文件的内容
cy.log(`Username from fixture: ${user.username}`);
expect(user.email).to.contain('@');
});
});
});
10. 自定义命令 (Custom Commands)
自定义命令允许你创建可复用的操作序列,使测试更简洁、更具可读性。定义在 cypress/support/commands.js
(旧版) 或 cypress/support/e2e.js
中引入的 commands.js
。
cypress/support/commands.js
:
js
// cypress/support/commands.js
/**
* Logs in a user with a given username and password.
* @param {string} username - The username.
* @param {string} password - The password.
* @example cy.login('testuser', 'password123')
*/
Cypress.Commands.add('login', (username, password) => {
cy.log(`Logging in as ${username}`);
cy.visit('/login'); // 假设登录页路由
cy.get('[data-cy="username-input"]').type(username);
cy.get('[data-cy="password-input"]').type(password);
cy.get('[data-cy="login-button"]').click();
// 可选:断言登录成功,例如 URL 改变或欢迎信息出现
cy.url().should('include', '/dashboard'); // 假设登录后跳转到 dashboard
});
/**
* Gets an element by its data-cy attribute.
* @param {string} selector - The value of the data-cy attribute.
* @example cy.getByCy('submit-button')
*/
Cypress.Commands.add('getByCy', (selector, ...args) => {
return cy.get(`[data-cy=${selector}]`, ...args);
});
/**
* A more complex command that might interact with an API and UI.
* Creates an item via API bellezza then verifies it on the UI.
* @param {object} itemData - Data for the item to be created.
*/
Cypress.Commands.add('createItemViaApiAndVerify', (itemData) => {
cy.request('POST', '/api/items', itemData).then((response) => {
expect(response.status).to.eq(201);
const itemId = response.body.id;
cy.visit(`/items/${itemId}`);
cy.getByCy('item-name-display').should('have.text', itemData.name);
});
});
// 如果自定义命令返回一个 Promise,Cypress 会自动处理
Cypress.Commands.add('fetchToken', (username, password) => {
return cy.request({
method: 'POST',
url: '/api/auth/token',
body: { username, password }
}).then(response => response.body.token);
});
// 覆盖 Cypress 原有命令 (谨慎使用)
// Cypress.Commands.overwrite('type', (originalFn, element, text, options) => {
// if (options && options.sensitive) {
// // Mask sensitive text in the command log
// options.log = false; // Disable logging for this specific call
// Cypress.log({
// name: 'type',
// message: '*'.repeat(text.length),
// $el: element,
// consoleProps: () => {
// return {
// 'Typed text': '*'.repeat(text.length),
// 'Original text': text,
// 'Applied to': element,
// };
// },
// });
// }
// return originalFn(element, text, options);
// });
确保在 cypress/support/e2e.js
(Cypress 10+) 或 cypress/support/index.js
(旧版) 中导入 commands.js
:
javascript
// cypress/support/e2e.js
import './commands';
在测试中使用自定义命令:
// cypress/e2e/custom_commands_test.cy.js
describe('Using Custom Commands', () => {
it('should login using the custom login command', () => {
cy.login('standard_user', 'secret_sauce'); // 使用自定义的 login 命令
cy.getByCy('welcome-message').should('contain.text', 'Welcome, standard_user');
});
it('should use getByCy to find elements', () => {
cy.visit('/some-page');
cy.getByCy('search-input').type('Cypress');
cy.getByCy('search-button').click();
cy.getByCy('search-results').should('be.visible');
});
it('should use fetchToken command', () => {
cy.fetchToken('user', 'pass').then(token => {
expect(token).to.be.a('string');
// localStorage.setItem('authToken', token); // 可以在这里使用 token
});
});
// it('should use overwritten type for sensitive data', () => {
// cy.visit('/login');
// cy.getByCy('username-input').type('testuser');
// cy.getByCy('password-input').type('supersecret', { sensitive: true }); // 密码不会明文显示在日志中
// });
});
11. 页面对象模型 (Page Object Model - POM)
POM 是一种设计模式,用于增强测试用例的可维护性和可读性。它将 UI 页面或页面片段抽象为对象,这些对象封装了与页面交互的方法和元素定位符。
优势:
- 代码复用: 页面交互逻辑封装在 Page Object 中,可在多个测试用例中复用。
- 可维护性: 如果 UI 发生变化,只需修改对应的 Page Object,而不需要修改所有使用该页面的测试用例。
- 可读性: 测试用例更关注业务流程,而不是底层的 UI 交互细节。
创建 Page Object 类 (例如,在 cypress/page-objects/
目录下)
cypress/page-objects/LoginPage.js
:
js
// cypress/page-objects/LoginPage.js
class LoginPage {
// 元素定位符
get usernameInput() {
return cy.get('[data-cy="username-input"]');
}
get passwordInput() {
return cy.get('[data-cy="password-input"]');
}
get loginButton() {
return cy.get('[data-cy="login-button"]');
}
get errorMessage() {
return cy.get('[data-cy="error-message"]');
}
// 页面交互方法
visit() {
cy.visit('/login');
return this; // 支持链式调用
}
typeUsername(username) {
this.usernameInput.type(username);
return this;
}
typePassword(password) {
this.passwordInput.type(password);
return this;
}
clickLogin() {
this.loginButton.click();
// 通常,点击登录后会导航到新页面,可以返回新的 Page Object
// return new DashboardPage();
}
login(username, password) {
this.typeUsername(username);
this.typePassword(password);
this.clickLogin();
// return new DashboardPage();
}
getErrorMessageText() {
return this.errorMessage.invoke('text'); // 获取文本内容
}
}
export default new LoginPage(); // 导出单例,或在测试中 new LoginPage()
// 导出单例方便,但如果页面有状态或需要多次实例化,则导出类本身更好
// export default LoginPage;
cypress/page-objects/DashboardPage.js
:
js
// cypress/page-objects/DashboardPage.js
class DashboardPage {
get welcomeMessage() {
return cy.get('[data-cy="welcome-message"]');
}
get logoutButton() {
return cy.get('[data-cy="logout-button"]');
}
verifyWelcomeMessage(username) {
this.welcomeMessage.should('be.visible').and('contain.text', `Welcome, ${username}`);
return this;
}
clickLogout() {
this.logoutButton.click();
// return new LoginPage();
}
}
export default new DashboardPage();
// export default DashboardPage;
在测试中使用 Page Objects:
js
// cypress/e2e/pom_login_test.cy.js
import loginPage from '../page-objects/LoginPage'; // 如果导出单例
import dashboardPage from '../page-objects/DashboardPage';
// import LoginPage from '../page-objects/LoginPage'; // 如果导出类
// import DashboardPage from '../page-objects/DashboardPage';
describe('Login Functionality using POM', () => {
// const loginPage = new LoginPage(); // 如果导出类
// const dashboardPage = new DashboardPage();
beforeEach(() => {
loginPage.visit();
});
it('should login successfully with valid credentials', () => {
cy.fixture('user.json').then((userData) => {
loginPage.login(userData.username, userData.password);
// 如果 login 方法返回 DashboardPage 实例:
// const dashboardPageInstance = loginPage.login(userData.username, userData.password);
// dashboardPageInstance.verifyWelcomeMessage(userData.username);
});
// 假设登录后跳转,由 dashboardPage 处理后续断言
dashboardPage.verifyWelcomeMessage('testuser'); // 假设 fixture 中的 username 是 testuser
});
it('should display error message with invalid credentials', () => {
loginPage.login('invaliduser', 'wrongpassword');
loginPage.errorMessage.should('be.visible').and('contain.text', 'Invalid credentials');
// 或者 loginPage.getErrorMessageText().should('contain', 'Invalid credentials');
});
it('should logout successfully', () => {
cy.fixture('user.json').then((userData) => {
loginPage.login(userData.username, userData.password);
});
dashboardPage.verifyWelcomeMessage('testuser');
dashboardPage.clickLogout();
// 断言回到了登录页
cy.url().should('include', '/login');
loginPage.usernameInput.should('be.visible'); // 验证登录页元素可见
});
});
12. Cypress 配置
Cypress 的配置主要在 cypress.config.js
(Cypress 10+) 或 cypress.json
(旧版) 文件中。
cypress.config.js
示例:
js
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
// 全局配置
projectId: 'your-project-id', // 用于 Cypress Dashboard
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000, // 命令默认超时时间 (ms)
pageLoadTimeout: 60000, // 页面加载超时时间 (ms)
requestTimeout: 15000, // cy.request 超时时间
responseTimeout: 30000, // 等待 XHR 响应的超时时间
watchForFileChanges: true, // 是否监听文件变化并自动重跑测试 (仅限 cypress open)
video: true, // 是否录制测试过程视频 (cypress run 默认 true)
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
screenshotOnRunFailure: true, // cypress run 失败时自动截图
trashAssetsBeforeRuns: true, // 运行前清空 screenshots 和 videos 文件夹
// 环境变量
env: {
apiUrl: 'http://localhost:3001/api',
adminUsername: 'admin',
// adminPassword: ' ควรใช้ Cypress.env.json หรือตัวแปรสภาพแวดล้อมของระบบสำหรับข้อมูลลับ'
},
// E2E 测试特定配置
e2e: {
baseUrl: 'http://localhost:3000', // 测试应用的基础 URL, cy.visit('/') 会访问它
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', // 测试文件匹配模式
supportFile: 'cypress/support/e2e.js', // 全局支持文件
// excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
setupNodeEvents(on, config) {
// 在这里可以注册插件,监听 Node.js 事件
// 例如,集成代码覆盖率工具,或者执行一些构建前的任务
// require('@cypress/code-coverage/task')(on, config);
// on('task', {
// log(message) {
// console.log(message);
// return null;
// },
// seedDatabase(data) {
// // 在 Node 环境执行数据库操作
// // return db.seed(data);
// return 'Database seeded with: ' + JSON.stringify(data);
// }
// });
// 重要的是返回 config 对象,否则 Cypress 不会加载这些修改
return config;
},
},
// Component 测试特定配置 (如果使用)
// component: {
// devServer: {
// framework: 'react', // 或 'vue', 'angular'
// bundler: 'webpack', // 或 'vite'
// },
// specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
// supportFile: 'cypress/support/component.js',
// },
// 其他配置项,如 chromeWebSecurity: false (谨慎使用,用于处理跨域问题)
// reporter: 'mochawesome', // 配置报告器
// reporterOptions: {
// reportDir: 'cypress/reports/mochawesome',
// overwrite: false,
// html: false,
// json: true,
// },
});
13. 环境变量
可以通过多种方式设置环境变量,并在测试中使用 Cypress.env('varName')
获取。
-
cypress.config.js
中的env
对象 (如上例)。 -
cypress.env.json
文件 (在项目根目录,会被.gitignore
忽略,适合存放敏感信息)。json// cypress.env.json { "adminPassword": "supersecretpassword", "apiKey": "xyz123abc" }
-
命令行参数:
bashnpx cypress run --env foo=bar,baz=qux # 或者 CYPRESS_API_URL=http://test.api.com CYPRESS_USER_ID=123 npx cypress run
(前缀为
CYPRESS_
的系统环境变量会自动加载)
使用:
ini
const apiUrl = Cypress.env('apiUrl');
const adminPass = Cypress.env('adminPassword'); // 从 cypress.env.json 或命令行获取
cy.request(`${apiUrl}/users`);
// cy.login(Cypress.env('adminUsername'), adminPass);
14. 插件 (Plugins)
插件允许你扩展 Cypress 的行为,通过监听 Node.js 进程中的事件。在 Cypress 10+ 中,插件逻辑主要配置在 cypress.config.js
的 setupNodeEvents
函数中。
用途示例:
- 任务 (
task
) : 在 Node.js 环境中执行任意代码(如数据库操作、文件系统操作)。 - 文件预处理: 在测试运行前处理测试文件(如编译 TypeScript、CoffeeScript)。
- 修改配置: 动态修改 Cypress 配置。
- 集成第三方服务: 如代码覆盖率、可视化测试工具。
cypress.config.js
中 setupNodeEvents
示例:
js
// cypress.config.js (部分)
// ...
const fs = require('fs');
const path = require('path');
module.exports = defineConfig({
e2e: {
setupNodeEvents(on, config) {
on('task', {
log(message) {
console.log(`[Plugin Task Log] ${message}`);
return null; // task 必须返回 null 或一个 Promise
},
readFileMaybe(filename) {
const filePath = path.join(__dirname, 'cypress', 'fixtures', filename);
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, 'utf8');
}
return null;
},
// 示例:数据库操作 (需要安装相应的 DB 驱动)
// queryDb(query) {
// return new Promise((resolve, reject) => {
// dbConnection.query(query, (error, results) => {
// if (error) return reject(error);
// resolve(results);
// });
// });
// }
});
// 修改配置的示例
// config.defaultCommandTimeout = 10000;
// config.env.anotherVar = 'setByPlugin';
// 集成代码覆盖率
// require('@cypress/code-coverage/task')(on, config);
return config; // 必须返回 config 对象
},
// ...
},
});
在测试中使用 cy.task()
:
js
// cypress/e2e/tasks_test.cy.js
describe('Using Tasks', () => {
it('should use log task', () => {
const message = 'Hello from test to plugin!';
cy.task('log', message).then((result) => {
// result 会是 null,因为 task 返回 null
expect(result).to.be.null;
});
// 你会在运行 Cypress 的终端看到 "[Plugin Task Log] Hello from test to plugin!"
});
it('should read a file using task', () => {
cy.task('readFileMaybe', 'user.json').then((content) => {
expect(content).to.be.a('string');
const userData = JSON.parse(content);
expect(userData.username).to.equal('testuser');
});
cy.task('readFileMaybe', 'nonexistent.json').then((content) => {
expect(content).to.be.null;
});
});
// it('should query database using task', () => {
// cy.task('queryDb', 'SELECT * FROM users WHERE id = 1').then(users => {
// expect(users).to.have.lengthOf(1);
// expect(users[0].name).to.equal('Admin User');
// });
// });
});
15. 调试技巧
-
Cypress Test Runner:
- 时间旅行: 点击左侧命令日志中的任何命令,可以看到该命令执行时应用的快照。DOM 快照、网络请求详情等。
- 固定命令: 点击命令日志中的图钉图标,可以固定快照,方便检查。
- 控制台输出 :
cy.log()
的输出,以及console.log
(在cy.then()
中使用) 会显示在浏览器开发者工具的控制台中。
-
debugger
: 在测试代码中加入debugger;
语句,当浏览器开发者工具打开时,执行会在此处暂停。jscy.get('button').then(($button) => { debugger; // 可以在这里检查 $button // $button 是一个 jQuery 对象 });
-
cy.debug()
: 类似于debugger;
,但会在命令日志中打印更多信息,并暂停。jscy.get('input').type('hello').debug();
-
.pause()
: 暂停测试执行,允许你在 Test Runner 中手动恢复或单步执行后续命令。jscy.get('form').submit().pause();
-
浏览器开发者工具: 可以检查元素、网络请求、控制台错误等。
-
截图和视频 : Cypress 自动为失败的测试(在
cypress run
时)截图,并可以配置录制视频。
16. 运行测试
-
交互模式 (
cypress open
) :bashnpx cypress open # 或 npm run cy:open (如果已配置到 package.json)
打开 Test Runner GUI,选择浏览器,点击 spec 文件运行。适合编写和调试测试。
-
命令行模式 (
cypress run
) :bashnpx cypress run # 或 npm run cy:run
在无头浏览器 (默认 Electron,可配置) 中运行所有测试。适合 CI 环境。
-
指定浏览器:
npx cypress run --browser chrome
-
指定测试文件:
npx cypress run --spec "cypress/e2e/login_test.cy.js"
-
指定文件夹:
npx cypress run --spec "cypress/e2e/smoke-tests/*"
-
并行运行 (需要 Cypress Dashboard):
npx cypress run --parallel --record --key YOUR_RECORD_KEY
-
生成报告: Cypress 默认输出到控制台,可以集成 Mochawesome 等报告器。
如果配置了 Mochawesome:
bash# 安装 # npm install --save-dev mochawesome mochawesome-merge mochawesome-report-generator # cypress.config.js 中配置 reporter: 'mochawesome' 和 reporterOptions # 运行后,报告会生成在 cypress/reports/mochawesome # 可以用 mochawesome-merge 合并多个 JSON 报告,然后用 marge 生成 HTML 报告
-
17. CI/CD 集成
将 Cypress 测试集成到 CI/CD 流程(如 Jenkins, GitLab CI, GitHub Actions)非常重要。
基本步骤:
-
安装依赖: CI 服务器需要 Node.js 环境,并能够安装项目依赖 (包括 Cypress)。
bash# GitHub Actions 示例 # .github/workflows/ci.yml name: Cypress Tests on: [push] jobs: cypress-run: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' # 或你的项目版本 - name: Install dependencies run: npm ci # 使用 ci 更快更可靠 - name: Start server (if needed) # 如果你的应用需要一个运行中的服务器 # run: npm start & # 运行后台服务 # wait-on http://localhost:3000 # 等待服务启动 (需要安装 wait-on) - name: Cypress run # 使用 Cypress 官方 GitHub Action 可以简化很多配置 uses: cypress-io/github-action@v6 with: # command: npm run cy:run # 如果你的运行命令不同 start: npm start # Action 会帮你启动服务并等待 wait-on: 'http://localhost:3000' # URL Action 等待的服务 browser: chrome # 可选 # record: true # 如果使用 Cypress Dashboard # parallel: true # 如果使用并行测试 # env: # 如果需要传递环境变量给 Cypress # CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 用于 Dashboard 集成 # - name: Cypress run (manual setup, if not using cypress-io/github-action) # run: npm run cy:run -- --reporter junit --reporter-options "mochaFile=cypress/reports/junit-[hash].xml" # # 上传测试报告 # - name: Upload test results # uses: actions/upload-artifact@v3 # if: always() # 即使测试失败也上传 # with: # name: cypress-results # path: cypress/reports/ # - name: Upload screenshots and videos (if run failed) # uses: actions/upload-artifact@v3 # if: failure() # with: # name: cypress-artifacts-on-failure # path: | # cypress/screenshots # cypress/videos
-
运行应用服务器: E2E 测试需要一个运行中的应用实例。CI 脚本中需要包含启动应用服务器的步骤,并确保服务器在测试开始前已就绪。
-
执行
cypress run
: 使用命令行模式运行测试。 -
处理结果和报告: CI 流程应能捕获测试的退出码 (0 表示成功,非 0 表示失败)。可以配置生成 JUnit 或 HTML 报告,并将其作为构建产物保存。
-
Cypress Dashboard (可选但推荐) :
- 提供测试结果的可视化、历史记录、并行测试协调、失败截图/视频的云存储、与 GitHub/GitLab 等的集成(如 PR 评论)。
- 需要在
cypress.config.js
中设置projectId
,并在运行时传递--record --key YOUR_RECORD_KEY
。
总结与最佳实践
- 优先使用
data-*
属性选择器:提高测试的健壮性。 - 保持测试独立 :每个测试用例 (
it
块) 都应该可以独立运行,不依赖于其他测试用例的状态。使用beforeEach
来设置初始状态。 - 原子化测试:每个测试用例应专注于验证一个特定的功能点或用户行为。
- 避免在测试中加入过多逻辑:测试应该简单直接。复杂的逻辑应封装在自定义命令或 Page Objects 中。
- 善用
cy.intercept()
:控制网络请求,使测试更稳定、更快速,并能测试各种网络场景。 - 使用 Page Object Model (POM) :提高可维护性和可读性,尤其对于大型应用。
- 合理使用等待 :依赖 Cypress 的自动等待,避免不必要的
cy.wait(fixedTime)
。 - 编写清晰的失败信息:自定义命令或复杂断言中,如果失败,确保日志能清晰指示问题所在。
- 定期审查和维护测试:E2E 测试需要持续投入维护。
- 不要过度依赖 E2E 测试:E2E 测试是测试金字塔的顶端,应该与单元测试和集成测试结合使用。它们成本较高,应主要覆盖关键用户流程。
- 在 CI 中运行 E2E 测试:确保每次代码变更都经过 E2E 测试的验证。
- 利用 Cypress Dashboard 或类似工具:分析测试结果,管理测试运行。