文章

E2E测试深度解析

E2E测试深度解析

一句话概括

端到端测试(End-to-End Testing,E2E)是测试金字塔的顶层,它模拟真实用户在浏览器中完成完整操作流程——从页面加载、点击按钮、填写表单到验证最终结果——整个链条上的任何环节(前端代码、后端 API、数据库、第三方服务)都会被测试覆盖。在 2026 年的测试生态中,Cypress 和 Playwright 占据了 E2E 测试的主导地位:Cypress 以开发者体验(调试器、时间旅行截图)闻名,Playwright 以跨浏览器支持和速度快著称。E2E 测试的目标不是替代单元测试和组件测试,而是作为最后的”安全网”——确保所有集成部分在一起工作时没有断裂。

背景与意义

E2E 测试解决了什么问题?

单元测试可以验证函数逻辑的正确性,组件测试可以验证组件行为,但以下场景它们都无能为力:

  1. 用户登录流程:从输入账号密码、点击登录、等待重定向、到看到仪表盘页面——这是一个跨页面、跨组件、前后端交互的完整流程。
  2. 前后端集成问题:前端发送的请求格式和后端期望的格式是否一致?API 返回的数据结构是否和前端定义的类型匹配?
  3. 第三方服务集成:支付网关回调、OAuth 登录、CDN 资源加载——这些外部依赖出错时应用是否能优雅处理?
  4. 真实浏览器行为:JavaScript 执行顺序、CSS 渲染、WebSocket 连接、Service Worker 缓存——这些只有在真实浏览器中才能验证。

E2E 测试就是为覆盖这些场景而生的。

E2E 测试的投入与产出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
E2E 测试的 ROI (Return on Investment):

            投入成本                     产出价值
            ────────                     ────────
编写和维护成本高    ↑                    ✅ 覆盖最真实的用户场景
运行时间较长        │                    ✅ 发现集成层面的 bug
环境依赖多          │                    ✅ 兜底的安全网
                   │                    ✅ 回归测试的终极保障
                   └───────────────────

一个中等规模项目(20 个核心用户流程)的合理配置:
- 核心流程: 30 个 E2E 测试(涵盖登录、注册、购物、支付等)
- 维护成本: 每周约 2-4 小时
- 预期收益: 每次发版前拦截 80% 的回归 bug

面试地位

E2E 测试在中高级前端面试中出现的频率越来越高:

  • 问法 1:”请比较 Cypress 和 Playwright 的优缺点。”
  • 问法 2:”一个完整的 E2E 测试应该如何设计?”
  • 问法 3:”E2E 测试中的 Page Object 模式是什么?”
  • 问法 4:”E2E 测试和 API 测试有什么区别?”

这些问题考察的是候选人对质量保障体系的整体理解——知道在什么场景下应该引入 E2E 测试,以及如何写出可维护的 E2E 测试。

概念与定义

什么是 E2E 测试?

端到端测试通过自动化浏览器,模拟用户在真实浏览器中操作的完整流程,从用户输入到最终输出,验证整个系统是否正确工作。

核心术语

术语英文定义
断言Assertion检查应用状态是否符合预期
选择器Selector定位 DOM 元素的方式
Page ObjectPage Object Model将页面操作封装为类,提高可维护性
FixtureFixture测试用的固定数据(模拟 API 响应)
命令Command浏览器的操作指令(click、type、navigate)
拦截Intercept拦截网络请求并返回模拟数据
等待Wait等待特定条件满足后再继续

Cypress vs Playwright:核心对比

维度CypressPlaywright
架构与浏览器同进程运行(Node.js + 浏览器)通过 CDP 协议控制浏览器(进程分离)
浏览器支持Chrome、Edge、Firefox、ElectronChrome、Edge、Firefox、Safari(WebKit)
语言JavaScript/TypeScriptJS/TS/Python/Java/.NET
运行速度较快(同进程)快(异步架构)
调试体验极佳(时间旅行、截图、录像)良好(Trace Viewer)
网络拦截cy.intercept()page.route()
并行执行Dashboard 付费内置支持
社区生态非常丰富(插件众多)快速增长

核心知识点拆解

1. Cypress 基础用法

Cypress 以其卓越的开发者体验著称。它的核心 API 采用链式调用的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// cypress/e2e/login.cy.js - Cypress 基础测试

describe('登录流程', () => {
  // 每个测试前访问页面
  beforeEach(() => {
    cy.visit('http://localhost:3000/login');
  });

  it('应该成功登录并跳转到仪表盘', () => {
    // 1. 填写表单
    cy.get('[data-testid="email-input"]')
      .should('be.visible')
      .type('user@example.com');

    cy.get('[data-testid="password-input"]')
      .type('password123');

    // 2. 点击登录按钮
    cy.get('[data-testid="login-button"]')
      .click();

    // 3. 验证跳转
    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]')
      .should('contain', '欢迎回来');
  });

  it('输入错误密码应显示错误信息', () => {
    // 拦截登录 API 请求,返回 401
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { message: '邮箱或密码错误' }
    }).as('loginRequest');

    cy.get('[data-testid="email-input"]').type('wrong@example.com');
    cy.get('[data-testid="password-input"]').type('wrongpassword');
    cy.get('[data-testid="login-button"]').click();

    // 等待 API 响应
    cy.wait('@loginRequest');

    // 验证错误消息
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', '邮箱或密码错误');
  });

  it('点击"注册"链接应跳转到注册页', () => {
    cy.get('[data-testid="register-link"]').click();
    cy.url().should('include', '/register');
  });
});

// 更复杂的交互:购物车流程
describe('购物车流程', () => {
  beforeEach(() => {
    // 模拟用户登录状态
    cy.setCookie('session_token', 'valid-token');
    cy.visit('/products');
  });

  it('用户可以将商品加入购物车并结算', () => {
    // 1. 浏览商品列表
    cy.get('[data-testid="product-card"]')
      .should('have.length.at.least', 1)
      .first()
      .click();

    // 2. 在商品详情页点击"加入购物车"
    cy.url().should('include', '/products/');
    cy.get('[data-testid="add-to-cart"]').click();

    // 3. 验证购物车数量更新
    cy.get('[data-testid="cart-count"]')
      .should('be.visible')
      .and('have.text', '1');

    // 4. 进入购物车页面
    cy.get('[data-testid="cart-icon"]').click();
    cy.url().should('include', '/cart');

    // 5. 验证购物车中有商品
    cy.get('[data-testid="cart-item"]')
      .should('have.length', 1);

    // 6. 点击结算
    cy.get('[data-testid="checkout-button"]').click();

    // 7. 验证跳转到结算页面
    cy.url().should('include', '/checkout');
  });

  it('用户可以修改购物车中商品数量', () => {
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();

    cy.get('[data-testid="cart-icon"]').click();

    // 增加数量
    cy.get('[data-testid="increase-qty"]').click();
    cy.get('[data-testid="item-quantity"]').should('have.text', '2');

    // 验证总价更新
    cy.get('[data-testid="total-price"]').should('not.be.empty');
  });

  it('用户可以移除购物车中的商品', () => {
    // 添加商品
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();

    cy.get('[data-testid="cart-icon"]').click();

    // 删除商品
    cy.get('[data-testid="remove-item"]').click();

    // 验证购物车为空
    cy.get('[data-testid="empty-cart-message"]')
      .should('be.visible')
      .and('contain', '购物车是空的');
  });
});

2. Cypress 高级特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// === Cypress 自定义命令 ===

// cypress/support/commands.js

// 自定义登录命令
Cypress.Commands.add('login', (email, password) => {
  cy.session([email, password], () => {
    cy.visit('/login');
    cy.get('[data-testid="email-input"]').type(email);
    cy.get('[data-testid="password-input"]').type(password);
    cy.get('[data-testid="login-button"]').click();
    cy.url().should('include', '/dashboard');
  });
});

// 在测试中使用
describe('仪表盘功能', () => {
  beforeEach(() => {
    cy.login('user@example.com', 'password123');
    cy.visit('/dashboard');
  });

  it('应该显示用户信息', () => {
    cy.get('[data-testid="user-name"]').should('contain', '张三');
    cy.get('[data-testid="user-stats"]').should('be.visible');
  });
});

// === Fixture 使用 ===
// cypress/fixtures/user.json
// {
//   "id": 1,
//   "name": "张三",
//   "email": "user@example.com",
//   "role": "admin"
// }

describe('使用 Fixture 数据', () => {
  beforeEach(() => {
    cy.fixture('user').as('userData');
  });

  it('使用 fixture 中的数据', function() {
    // fixture 通过 this.userData 访问
    cy.intercept('GET', '/api/user', { body: this.userData });

    cy.visit('/profile');
    cy.get('[data-testid="user-name"]').should('contain', this.userData.name);
  });
});

3. Playwright 基础用法

Playwright 由微软开发,支持多浏览器(Chromium、Firefox、WebKit)和多语言。它的 API 设计更现代,采用 async/await 模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// tests/e2e/login.spec.ts - Playwright 测试

import { test, expect } from '@playwright/test';

test.describe('登录流程', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/login');
  });

  test('应该成功登录并跳转到仪表盘', async ({ page }) => {
    // 1. 填写表单
    await page.getByTestId('email-input').fill('user@example.com');
    await page.getByTestId('password-input').fill('password123');

    // 2. 点击登录按钮
    await page.getByTestId('login-button').click();

    // 3. 等待导航完成
    await page.waitForURL('**/dashboard');

    // 4. 验证页面内容
    await expect(page.getByTestId('welcome-message')).toContainText('欢迎回来');
  });

  test('输入错误密码应显示错误信息', async ({ page }) => {
    // 拦截 API 请求
    await page.route('**/api/login', async (route) => {
      await route.fulfill({
        status: 401,
        contentType: 'application/json',
        body: JSON.stringify({ message: '邮箱或密码错误' })
      });
    });

    await page.getByTestId('email-input').fill('wrong@example.com');
    await page.getByTestId('password-input').fill('wrongpassword');
    await page.getByTestId('login-button').click();

    // 验证错误消息
    await expect(page.getByTestId('error-message')).toBeVisible();
    await expect(page.getByTestId('error-message')).toContainText('邮箱或密码错误');
  });

  test('应该验证表单字段的必填性', async ({ page }) => {
    // 不填写表单直接提交
    await page.getByTestId('login-button').click();

    // HTML5 验证会阻止提交——检查是否还在登录页
    await expect(page).toHaveURL(/\/login/);

    // 或者在 JS 验证的情况下,检查错误提示
    const emailValidation = await page.getByTestId('email-input').getAttribute('validationMessage');
    expect(emailValidation).toBeTruthy();
  });
});

// 测试购物车流程
test.describe('购物车流程', () => {
  test('完整的购物流程:从浏览到下单', async ({ page }) => {
    // 1. 浏览商品列表
    await page.goto('/products');
    await expect(page.getByTestId('product-card')).toHaveCount(10);

    // 2. 筛选商品
    await page.getByRole('combobox', { name: '分类' }).selectOption('电子产品');
    await expect(page.getByTestId('product-card')).toHaveCount(3);

    // 3. 查看商品详情
    await page.getByTestId('product-card').first().click();
    await expect(page.locator('h1')).toBeVisible();

    // 4. 加入购物车
    await page.getByTestId('add-to-cart').click();
    await expect(page.getByTestId('cart-count')).toHaveText('1');

    // 5. 去购物车结算
    await page.getByTestId('cart-icon').click();
    await expect(page.getByTestId('cart-item')).toHaveCount(1);

    // 6. 填写收货地址
    await page.getByTestId('checkout-button').click();
    await page.getByLabel('收货人').fill('张三');
    await page.getByLabel('手机号').fill('13800138000');
    await page.getByLabel('详细地址').fill('北京市海淀区中关村大街1号');

    // 7. 提交订单
    await page.getByRole('button', { name: '提交订单' }).click();

    // 8. 验证成功页面
    await expect(page.getByText('订单提交成功')).toBeVisible();
    await expect(page.getByTestId('order-number')).toBeVisible();
  });

  test('跨标签页操作', async ({ context }) => {
    // 打开新标签页
    const page1 = await context.newPage();
    await page1.goto('/products');

    // 新标签页
    const page2 = await context.newPage();
    await page2.goto('/about');

    // 在多个标签页中共享登录状态
    await page1.getByTestId('login-button').click();
    // page2 也会处于登录状态(因为共享 session)
    await page2.goto('/dashboard');
    await expect(page2.getByTestId('welcome-message')).toBeVisible();
  });

  test('响应式布局测试', async ({ page }) => {
    // 模拟移动端视口
    await page.setViewportSize({ width: 375, height: 812 });

    await page.goto('/products');

    // 移动端应该是汉堡菜单
    await expect(page.getByTestId('hamburger-menu')).toBeVisible();
    await expect(page.getByTestId('desktop-nav')).not.toBeVisible();
  });
});

4. Playwright 高级特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// === Playwright Trace Viewer + 截图 + 录像 ===

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  timeout: 30000,
  retries: 2,
  use: {
    baseURL: 'http://localhost:3000',
    // 失败时自动截图
    screenshot: 'only-on-failure',
    // 录制视频
    video: 'retain-on-failure',
    // 收集追踪信息
    trace: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
    },
    {
      name: 'firefox',
      use: { browserName: 'firefox' },
    },
    {
      name: 'webkit',
      use: { browserName: 'webkit' },
    },
  ],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// === Page Object 模式(PO模式) ===

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;
  readonly registerLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.loginButton = page.getByTestId('login-button');
    this.errorMessage = page.getByTestId('error-message');
    this.registerLink = page.getByTestId('register-link');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async getErrorMessage() {
    return this.errorMessage.textContent();
  }

  async clickRegister() {
    await this.registerLink.click();
  }
}

// pages/DashboardPage.ts
export class DashboardPage {
  readonly page: Page;
  readonly welcomeMessage: Locator;
  readonly stats: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeMessage = page.getByTestId('welcome-message');
    this.stats = page.getByTestId('user-stats');
  }

  async getWelcomeText() {
    return this.welcomeMessage.textContent();
  }

  async isStatsVisible() {
    return this.stats.isVisible();
  }
}

// 在测试中使用 Page Object
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('使用 Page Object 的测试', () => {
  test('登录成功后应看到仪表盘', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const dashboardPage = new DashboardPage(page);

    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');

    await page.waitForURL('**/dashboard');
    expect(await dashboardPage.getWelcomeText()).toContain('欢迎回来');
    expect(await dashboardPage.isStatsVisible()).toBe(true);
  });

  test('登录失败应显示错误', async ({ page }) => {
    await page.route('**/api/login', async (route) => {
      await route.fulfill({
        status: 401,
        body: JSON.stringify({ message: '账号或密码错误' })
      });
    });

    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('wrong@example.com', 'wrongpass');

    expect(await loginPage.getErrorMessage()).toContain('账号或密码错误');
  });
});

实战案例

构建完整的 E2E 测试套件

下面是一个电商应用的完整 E2E 测试套件,涵盖核心业务流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
// tests/e2e/ecommerce.spec.ts
import { test, expect, Page } from '@playwright/test';

// 辅助函数:创建测试用户
async function createTestUser(page: Page) {
  const testUser = {
    email: `test-${Date.now()}@example.com`,
    password: 'TestPass123!',
    name: '测试用户'
  };

  await page.goto('/register');
  await page.getByLabel('用户名').fill(testUser.name);
  await page.getByLabel('邮箱').fill(testUser.email);
  await page.getByLabel('密码').fill(testUser.password);
  await page.getByLabel('确认密码').fill(testUser.password);
  await page.getByRole('button', { name: '注册' }).click();

  await page.waitForURL('**/dashboard');
  return testUser;
}

test.describe('电商平台 E2E 测试', () => {
  let testUser: { email: string; password: string; name: string };

  test.beforeEach(async ({ page }) => {
    // 每个测试前注册新用户
    testUser = await createTestUser(page);
  });

  test('搜索和浏览商品', async ({ page }) => {
    // 1. 使用搜索功能
    await page.goto('/');
    await page.getByTestId('search-input').fill('无线耳机');
    await page.getByTestId('search-button').click();

    // 搜索结果页
    await expect(page.getByTestId('search-results')).toBeVisible();
    await expect(page.getByTestId('search-results')).toContainText('无线耳机');

    // 2. 使用分类筛选
    await page.getByRole('combobox', { name: '价格范围' }).selectOption('100-500');
    await page.waitForResponse('**/api/products/**');

    // 验证筛选后的商品数量
    const productCards = page.getByTestId('product-card');
    const count = await productCards.count();
    expect(count).toBeGreaterThan(0);

    // 3. 查看分页功能
    if (await page.getByTestId('next-page').isVisible()) {
      await page.getByTestId('next-page').click();
      await page.waitForResponse('**/api/products/**');
      await expect(page).toHaveURL(/page=2/);
    }
  });

  test('完整购物流程', async ({ page }) => {
    // Step 1: 浏览商品并加入购物车
    await page.goto('/products');
    await page.getByTestId('product-card').first().click();

    const productName = await page.getByTestId('product-name').textContent();
    const productPrice = await page.getByTestId('product-price').textContent();

    // 选择规格
    if (await page.getByTestId('size-selector').isVisible()) {
      await page.getByTestId('size-selector').locator('label').first().click();
    }

    // 加入购物车
    await page.getByTestId('add-to-cart').click();
    await expect(page.getByText('已添加到购物车')).toBeVisible({ timeout: 5000 });

    // Step 2: 验证购物车
    await page.getByTestId('cart-icon').click();
    await expect(page.getByTestId('cart-item')).toHaveCount(1);
    await expect(page.getByTestId('cart-item-name')).toContainText(productName!);

    // Step 3: 结算
    await page.getByTestId('checkout-button').click();

    // 填写收货信息
    await page.getByLabel('收货人').fill(testUser.name);
    await page.getByLabel('手机号').fill('13800138000');
    await page.getByLabel('省份').selectOption('北京市');
    await page.getByLabel('详细地址').fill('中关村大街1号');
    await page.getByLabel('邮编').fill('100000');

    // 选择支付方式
    await page.getByRole('radio', { name: '微信支付' }).click();

    // Step 4: 提交订单
    await page.getByRole('button', { name: '提交订单' }).click();

    // Step 5: 验证订单创建成功
    await expect(page.getByTestId('order-success')).toBeVisible({ timeout: 10000 });
    await expect(page.getByTestId('order-number')).toBeVisible();

    const orderNumber = await page.getByTestId('order-number').textContent();
    expect(orderNumber).toMatch(/^ORD\d{10,}$/);

    // Step 6: 查看订单详情
    await page.getByRole('link', { name: '查看订单' }).click();
    await expect(page.getByTestId('order-status')).toContainText('待付款');
    await expect(page.getByTestId('order-total')).toBeVisible();
    await expect(page.getByTestId('order-total')).toContainText(productPrice!);
  });

  test('优惠券使用流程', async ({ page }) => {
    // 先添加商品到购物车
    await page.goto('/products');
    await page.getByTestId('product-card').first().click();
    await page.getByTestId('add-to-cart').click();

    // 去购物车
    await page.goto('/cart');

    // 输入优惠券码
    await page.getByTestId('coupon-input').fill('SAVE50');
    await page.getByTestId('apply-coupon').click();

    // 验证优惠已应用
    await expect(page.getByTestId('discount-row')).toBeVisible();
    await expect(page.getByTestId('discount-amount')).not.toHaveText('¥0');

    // 验证总价计算正确
    const subtotal = await page.getByTestId('subtotal').textContent();
    const discount = await page.getByTestId('discount-amount').textContent();
    const total = await page.getByTestId('total').textContent();

    // subtotal - discount = total
    const subtotalNum = parseFloat(subtotal!.replace('¥', ''));
    const discountNum = parseFloat(discount!.replace('¥', ''));
    const totalNum = parseFloat(total!.replace('¥', ''));
    expect(totalNum).toBeCloseTo(subtotalNum - discountNum, 2);
  });

  test('支付失败后的重新支付', async ({ page }) => {
    // 创建一个订单
    await page.goto('/products');
    await page.getByTestId('product-card').first().click();
    await page.getByTestId('add-to-cart').click();
    await page.goto('/checkout');
    await page.getByLabel('收货人').fill(testUser.name);
    await page.getByLabel('手机号').fill('13800138000');
    await page.getByLabel('详细地址').fill('测试地址');
    await page.getByRole('button', { name: '提交订单' }).click();
    await expect(page.getByTestId('order-success')).toBeVisible();

    // 模拟支付失败
    await page.route('**/api/payment/**', async (route) => {
      await route.fulfill({
        status: 402,
        body: JSON.stringify({ error: '余额不足' })
      });
    });

    await page.getByRole('button', { name: '去支付' }).click();
    await expect(page.getByTestId('payment-failed')).toBeVisible({ timeout: 10000 });
    await expect(page.getByTestId('error-message')).toContainText('余额不足');

    // 修复支付方式后重新支付
    await page.route('**/api/payment/**', async (route) => {
      await route.fulfill({
        status: 200,
        body: JSON.stringify({ success: true })
      });
    });

    await page.getByRole('button', { name: '更换支付方式' }).click();
    await page.getByRole('radio', { name: '银行卡支付' }).click();
    await page.getByRole('button', { name: '确认支付' }).click();

    await expect(page.getByText('支付成功')).toBeVisible({ timeout: 10000 });
    await expect(page.getByTestId('order-status')).toContainText('已支付');
  });

  test('用户中心功能', async ({ page }) => {
    // 最近订单
    await page.goto('/orders');
    await expect(page.getByTestId('order-list')).toBeVisible();

    // 修改个人信息
    await page.goto('/settings/profile');
    await page.getByLabel('昵称').fill('新昵称');
    await page.getByRole('button', { name: '保存' }).click();
    await expect(page.getByText('保存成功')).toBeVisible();

    // 收货地址管理
    await page.goto('/settings/addresses');
    await page.getByRole('button', { name: '添加地址' }).click();
    await page.getByLabel('收货人').fill('张三');
    await page.getByLabel('手机号').fill('13900139000');
    await page.getByLabel('详细地址').fill('新地址');
    await page.getByRole('button', { name: '保存' }).click();
    await expect(page.getByTestId('address-item')).toHaveCount(1);
  });
});

底层原理

Cypress 的架构与执行机制

Cypress 的架构与传统 E2E 测试工具有根本不同——它不在浏览器外部通过 CDP(Chrome DevTools Protocol)控制浏览器,而是与浏览器运行在同一个进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// === Cypress 架构的简化示意 ===

// Cypress 的架构分为三层:
// 1. Cypress 主进程(Node.js)
// 2. Cypress 浏览器进程(iframe 中的测试代码)
// 3. 被测试的 Web 应用(在另一个 iframe 中)

class CypressArchitecture {
  constructor() {
    // 主进程:Node.js 服务器
    this.server = {
      // 负责:
      // - 提供代理服务器(拦截所有请求)
      // - 提供测试运行器的 UI
      // - 执行插件代码(node 环境)
      start: () => {},
      // 每个测试文件启动一个代理
      createProxy: (testFile) => {}
    };

    // 浏览器中的测试运行器
    this.runner = {
      // 在 iframe 中执行测试代码
      // 和被测应用在同源(受限于 iframe 策略)
      execute: (testCode) => {
        // 测试代码运行在浏览器中
        // 可以直接访问 DOM
        // 可以拦截网络请求
        // 可以操作 window、document
      }
    };

    // 被测试的应用
    this.appIframe = {
      // 在另一个 iframe 中加载被测应用
      // 与 runner iframe 同源
      load: (url) => {},
      // 获取当前的 DOM 状态
      getDOM: () => {}
    };
  }
}

// Cypress 的 cy 对象如何工作
// cy 对象不是 Promise,而是一个"命令队列"
class CypressCommands {
  constructor() {
    this.queue = [];
    this.currentSubject = null;
  }

  // 每次调用 cy.get()、cy.click() 等,实际上是在队列中添加命令
  get(selector) {
    this.queue.push({
      name: 'get',
      selector,
      // 在队列中存储
      execute: () => {
        this.currentSubject = Cypress.$(selector);
        return this;
      },
      // 重试机制(Cypress 自动重试)
      retryInterval: 50,
      maxRetries: 20
    });
    return this; // 支持链式调用
  }

  click(options) {
    this.queue.push({
      name: 'click',
      options,
      execute: () => {
        if (!this.currentSubject || this.currentSubject.length === 0) {
          throw new Error('No element to click');
        }
        this.currentSubject[0].click();
        return this;
      }
    });
    return this;
  }

  type(text) {
    this.queue.push({
      name: 'type',
      text,
      execute: () => {
        const input = this.currentSubject[0];
        input.value = '';
        // 逐字符输入,模拟真实键盘
        for (const char of text) {
          input.value += char;
          input.dispatchEvent(new Event('input', { bubbles: true }));
          input.dispatchEvent(new Event('keyup', { bubbles: true }));
        }
        input.dispatchEvent(new Event('change', { bubbles: true }));
        return this;
      }
    });
    return this;
  }

  // 执行命令队列(异步执行,每个命令都有超时和重试)
  async execute() {
    for (const command of this.queue) {
      let retries = 0;
      let lastError;

      while (retries < command.maxRetries) {
        try {
          // 每个命令执行前等待 DOM 更新
          await this.waitForAnimation();
          command.execute();
          break; // 成功,继续下一个命令
        } catch (error) {
          lastError = error;
          retries++;
          await this.sleep(command.retryInterval);
        }
      }

      if (retries >= command.maxRetries) {
        throw lastError;
      }
    }

    this.queue = [];
  }
}

// 自动重试机制(Cypress 的核心特性之一)
// 不同于其他框架需要手动等待,Cypress 会自动重试断言
function createAssertionChain() {
  return {
    should: (matcher, expected) => {
      // 自动重试直到断言通过或超时
      return {
        // 内部实现
        retryUntilPass: async (element, timeout = 4000) => {
          const startTime = Date.now();
          let lastError;

          while (Date.now() - startTime < timeout) {
            try {
              if (matcher === 'be.visible') {
                if (element.is(':visible')) return;
                throw new Error('Element not visible');
              } else if (matcher === 'contain') {
                if (element.text().includes(expected)) return;
                throw new Error(`Element doesn't contain "${expected}"`);
              }
              // ... 其他 matcher
            } catch (e) {
              lastError = e;
              await sleep(50);
            }
          }

          throw lastError;
        }
      };
    }
  };
}

Playwright 的 CDP 架构

Playwright 使用 Chrome DevTools Protocol(CDP)控制浏览器,这意味着测试代码和浏览器在不同进程中运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
// === Playwright 架构的简化示意 ===

class PlaywrightArchitecture {
  private browser: any;
  private browserContext: any;

  async launch() {
    // 1. 启动浏览器进程(独立进程)
    // Playwright 直接使用系统安装的浏览器或下载的浏览器
    const child = require('child_process');
    const browserProcess = child.spawn('/path/to/chrome', [
      '--remote-debugging-port=9222',
      '--headless=new',
      '--no-sandbox'
    ]);

    // 2. 通过 CDP 连接浏览器
    // CDP = Chrome DevTools Protocol
    // 这是一个 WebSocket 连接,发送 JSON 命令
    const ws = new WebSocket('ws://localhost:9222/devtools/browser');
    this.browser = new BrowserProxy(ws);
  }

  async createContext() {
    // 3. 创建浏览器上下文(类似匿名模式的新窗口)
    // 不同 context 之间完全隔离(cookie、storage、cache)
    this.browserContext = await this.browser.createContext({
      viewport: { width: 1280, height: 720 },
      locale: 'zh-CN',
      timezoneId: 'Asia/Shanghai'
    });
  }

  async newPage() {
    // 4. 创建新页面(Tab)
    const session = await this.browserContext.createSession();
    return new Page(session);
  }
}

class Page {
  constructor(private session: any) {}

  // 每个操作都通过 CDP 发送命令
  async click(selector: string) {
    // 1. 首先在页面上查找元素
    const { result } = await this.session.send('Runtime.evaluate', {
      expression: `document.querySelector('${selector}')`,
      returnByValue: false
    });

    const objectId = result.objectId;

    // 2. 获取元素的 bounding box
    const { result: box } = await this.session.send('Runtime.callFunctionOn', {
      objectId,
      functionDeclaration: 'function() { const r = this.getBoundingClientRect(); return {x: r.x, y: r.y, width: r.width, height: r.height}; }'
    });

    // 3. 计算点击位置
    const x = box.value.x + box.value.width / 2;
    const y = box.value.y + box.value.height / 2;

    // 4. 模拟鼠标事件
    await this.session.send('Input.dispatchMouseEvent', {
      type: 'mousePressed',
      x,
      y,
      button: 'left',
      clickCount: 1
    });

    await this.session.send('Input.dispatchMouseEvent', {
      type: 'mouseReleased',
      x,
      y,
      button: 'left',
      clickCount: 1
    });
  }

  async type(selector: string, text: string) {
    // 1. 聚焦元素
    await this.session.send('Runtime.evaluate', {
      expression: `document.querySelector('${selector}').focus()`
    });

    // 2. 逐字符输入
    for (const char of text) {
      await this.session.send('Input.dispatchKeyEvent', {
        type: 'keyDown',
        key: char,
        code: `Key${char.toUpperCase()}`
      });

      await this.session.send('Input.dispatchKeyEvent', {
        type: 'keyUp',
        key: char,
        code: `Key${char.toUpperCase()}`
      });
    }
  }

  async waitForSelector(selector: string, timeout = 30000) {
    const startTime = Date.now();
    while (Date.now() - startTime < timeout) {
      const { result } = await this.session.send('Runtime.evaluate', {
        expression: `document.querySelector('${selector}') !== null`,
        returnByValue: true
      });

      if (result.value) return;

      // 等待帧更新
      await this.sleep(100);
    }

    throw new Error(`Timeout waiting for selector: ${selector}`);
  }

  // 网络拦截
  async route(url: string | RegExp, handler: (route: any) => void) {
    // 拦截网络请求并重写响应
    this.session.on('Network.requestIntercepted', (params: any) => {
      if (url instanceof RegExp ? url.test(params.request.url) : params.request.url.includes(url)) {
        // 让用户提供的 handler 处理
        handler({
          continue: () => {
            this.session.send('Network.continueInterceptedRequest', {
              interceptionId: params.interceptionId
            });
          },
          fulfill: (response: any) => {
            this.session.send('Network.continueInterceptedRequest', {
              interceptionId: params.interceptionId,
              rawResponse: Buffer.from(
                `HTTP/1.1 ${response.status} OK\r\nContent-Type: ${response.contentType}\r\n\r\n${JSON.stringify(response.body)}`
              ).toString('base64')
            });
          }
        });
      } else {
        // 不拦截
        this.session.send('Network.continueInterceptedRequest', {
          interceptionId: params.interceptionId
        });
      }
    });

    // 启用网络拦截
    await this.session.send('Network.enable');
    await this.session.send('Network.setRequestInterception', {
      patterns: [{ urlPattern: '*' }]
    });
  }
}

高频面试题解析

面试题 1:Cypress 和 Playwright 的核心区别是什么?你如何选择?

答案要点

核心区别

维度CypressPlaywright
架构同进程(浏览器 iframe + Node.js)异进程(CDP 协议控制)
浏览器支持主要 Chrome 系列全浏览器(含 Safari)
多标签/多页面有限支持(通过 cy.origin)原生支持
跨域控制受限(同源策略)不受限
调试体验时间旅行、截图、录像Trace Viewer
性能
语言JS/TS多语言

选择建议

  • 选择 Cypress:更看重调试体验,项目主要面向 Chrome 用户,团队 JS 技术栈。
  • 选择 Playwright:需要跨浏览器测试,需要多标签页操作,性能要求高,需要多语言支持。

面试题 2:E2E 测试中如何处理网络请求(Mock vs 真实 API)?

答案要点

两种策略的选择

Mock API(拦截网络请求)

  • 优点:测试速度快、不受后端影响、可模拟各种异常场景。
  • 缺点:与真实 API 的偏差可能导致”测试通过,线上失败”。
  • 适用场景:前端主导的 E2E 测试、异常路径测试、开发环境。

真实 API

  • 优点:最真实的测试,前后端集成完全验证。
  • 缺点:慢、依赖后端环境、需要测试数据管理。
  • 适用场景:CI/CD 流水线集成测试、预发布环境验证。

最佳实践

1
2
3
4
5
6
混合策略:
- 核心流程(Happy Path):真实 API
- 异常流程(网络错误、超时、错误响应):Mock
- 第三方依赖(支付、登录、地图):Mock
- 独立测试/开发阶段:Mock
- CI/CD 最终验证:真实 API

面试题 3:如何提高 E2E 测试的稳定性(减少 Flaky Tests)?

答案要点

Flaky Test 的原因

  1. 竞态条件:测试执行速度与页面渲染速度不匹配。
  2. 环境不稳定:网络波动、服务未就绪。
  3. 测试数据污染:共同依赖的关键数据被其他测试修改。
  4. 时间相关:动画、过渡、定时器未完全执行。

解决策略

  1. 使用智能等待,而非固定时间
    1
    2
    3
    4
    5
    6
    
    // ❌ 固定等待(不可靠)
    await page.waitForTimeout(3000);
       
    // ✅ 智能等待
    await expect(page.getByText('加载完成')).toBeVisible({ timeout: 10000 });
    await page.waitForSelector('[data-testid="loaded"]');
    
  2. 测试间隔离
    • 每个测试使用独立数据(如唯一用户名)。
    • 测试前清理数据。
    • 使用隔离的数据库或测试容器。
  3. 重试机制
    1
    2
    3
    4
    5
    6
    7
    8
    
    // Playwright 自动重试
    test('稳定的测试', async ({ page }) => {
      // Playwright 的断言会自动重试
      await expect(page.getByText('成功')).toBeVisible();
    });
       
    // Cypress 自动重试
    cy.get('[data-testid="result"]').should('contain', '成功');
    
  4. 避免直接依赖 URL
    1
    2
    3
    4
    5
    6
    
    // ❌ 依赖特定 URL
    await page.goto('http://localhost:3000/products/123');
       
    // ✅ 通过 UI 导航
    await page.getByText('商品列表').click();
    await page.getByTestId('product-card').first().click();
    
  5. 使用专用的测试环境
    • 测试数据库(随时重置)。
    • Mock 外部服务。
    • 固定测试数据。

面试题 4:什么是 Page Object 模式?为什么在 E2E 测试中推荐使用?

答案要点

定义:Page Object 模式是一种设计模式,将页面或页面组件的操作封装为类。

核心思想

  • 每个页面封装为一个类。
  • 页面元素的定位器(Selector)集中在类中。
  • 页面操作封装为类的方法。
  • 测试逻辑与页面结构解耦。

优点

  1. 可维护性:页面结构变化时只需修改 Page Object,不修改测试用例。
  2. 可读性loginPage.login('user', 'pass')page.fill('#email', 'user'); page.fill('#pass', 'pass'); page.click('#submit') 清晰得多。
  3. 复用性:同一个 Page Object 可以在多个测试中复用。
  4. 减少重复:选择器集中管理,不会散落在各个测试文件中。

示例

1
2
3
4
5
6
7
8
9
10
11
12
// 不使用 Page Object
test('登录', async ({ page }) => {
  await page.fill('#email-input', 'user@example.com');
  await page.fill('#password-input', 'password');
  await page.click('#login-button');
  await expect(page.locator('#welcome')).toBeVisible();
});

// 使用 Page Object
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password');
await expect(loginPage.welcomeMessage).toBeVisible();

进阶:对于复杂页面可以使用 Component Object 模式,将页面中的组件(如 Header、Sidebar、Modal)也封装为类。

面试题 5:E2E 测试和 API 测试的区别是什么?如何配合使用?

答案要点

区别

维度E2E 测试API 测试
测试对象完整 UI 流程API 接口
验证内容用户界面 + 功能请求/响应/状态码/数据结构
环境真实浏览器HTTP 客户端
速度慢(秒级)快(毫秒级)
稳定性较低(受 UI 变化影响)高(接口变动频率低)
覆盖范围用户端到端服务端逻辑

配合使用策略

1
2
3
4
5
6
7
8
9
10
11
API 测试:快速验证后端接口  ✅
  └─ 覆盖:所有接口的 Happy Path + 错误场景
  └─ 频率:每次提交

组件测试:验证 UI 组件行为  ✅
  └─ 覆盖:组件状态、交互、渲染
  └─ 频率:每次提交

E2E 测试:验证核心用户流程  ✅
  └─ 覆盖:3-5 个最关键的端到端流程
  └─ 频率:合并前、发布前

最佳实践:不要用 E2E 测试验证 API,也不要用 API 测试验证 UI。各司其职,组合成一个完整的安全网。

总结与扩展

知识体系

E2E 测试的知识体系:

  • 工具框架:Cypress、Playwright、Selenium、Puppeteer。
  • 架构模式:Page Object、Component Object、Fixture、Data-Driven。
  • 测试策略:Happy Path、Sad Path、Boundary、Security、Performance。
  • 基础设施:CI/CD 集成、Docker 容器、浏览器农场、并行执行。
  • 报告与监控:Allure 报告、Screenshots、Video Recording、Trace Viewer。
  • 最佳实践:隔离测试、智能等待、数据清理、重试策略。

未来趋势

  1. AI 辅助测试生成:通过 LLM 从设计稿或需求文档自动生成 E2E 测试。
  2. Component-Level E2E:Storybook + Playwright 的组件级集成测试。
  3. Web Component 测试:微前端架构下的跨应用 E2E 测试。
  4. 视觉回归测试:E2E 测试结合截图对比(Percy、Chromatic)。

延伸阅读

E2E 测试是一把双刃剑——写得好,它是项目的安全网;写得不好,它是团队的时间黑洞。正确的态度是:只覆盖最关键的用户流程,把细节留给单元测试和组件测试

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量