25 KiB
25 KiB
测试架构:E2E 测试 vs 组件测试 vs API 测试
使用时机:当需要为一个功能决定编写哪种测试时。在写任何测试之前,先问:"什么是最便宜的、能让我对功能有信心的测试?"
快速回答
大多数团队写了太多 E2E 测试。默认采用 testing trophy(测试奖杯) 方法:
- API 测试——用于业务逻辑、数据验证和权限(最快、最稳定)
- 组件测试——用于隔离的 UI 行为、表单验证和交互式控件(快速、聚焦)
- E2E 测试仅用于关键用户路径——登录、结账、引导流程(慢、最高信心)
如果你只为一个层面腾出时间,先从 API 测试开始。它们以最低的维护成本覆盖最多的范围。
测试奖杯
测试奖杯(由 Kent C. Dodds 提出)取代了传统 Web 应用中的测试金字塔:
╱ ╲
╱ E2E ╲ 薄层——仅限关键路径
╱─────────╲
╱ ╲
╱ Integration ╲ 最厚层——组件 + API 测试
╱ ╲
╱───────────────────╲
╱ Unit ╲ 工具函数、纯逻辑
╱───────────────────────╲
Static Analysis TypeScript、ESLint、Prettier
Playwright 的定位:
- E2E 层:
@playwright/test配合浏览器——完整的用户流程测试 - 集成/组件层:
@playwright/experimental-ct-*——在真实浏览器中渲染组件,无需完整应用 - 集成/API 层:
@playwright/test配合request上下文——无需浏览器的 HTTP 测试 - 静态层:不是 Playwright 的职责。使用 TypeScript 和 ESLint。
奖杯形状意味着集成测试(组件 + API)应该是你投入最大的部分。它们在信心与成本之间提供了最佳比例。
决策矩阵
| 测试对象 | 测试类型 | 原因 | Playwright 示例 |
|---|---|---|---|
| 登录/认证流程 | E2E | 跨页面、Cookie、重定向、会话状态 | 带 storageState 的完整浏览器流程 |
| 表单提交 | 组件测试 | 隔离的验证逻辑、错误状态、UX | 挂载表单组件,测试各种状态 |
| CRUD 操作 | API 测试 | 数据的完整性比 UI 更重要(创建/更新/删除) | request.post()、request.put()、request.delete() |
| 带结果 UI 的搜索 | 组件+API 测试 | API 测试查询逻辑;组件测试渲染结果 | 拆分:API 测数据,组件测展示 |
| 跨页面导航 | E2E | 路由、历史记录、深度链接是浏览器层面的关注点 | page.goto()、page.waitForURL() |
| 错误处理(API 错误) | API 测试 | 验证状态码、错误结构、边界情况,无需 UI | expect(response.status()).toBe(422) |
| 错误处理(UI 反馈) | 组件测试 | Toast、Banner、内联错误渲染 | 挂载组件,模拟错误响应 |
| 可访问性 | 组件测试 | 按组件测试 ARIA 角色、键盘导航;比完整 E2E 更快 | expect(locator).toHaveAttribute('aria-expanded') |
| 响应式布局 | 组件测试 | 特定视口下的渲染,无需完整应用开销 | 带 viewport 配置的 mount() |
| API 集成(契约) | API 测试 | 独立验证响应结构、头部、认证 | 带 Schema 验证的 request.get() |
| 实时功能(WebSocket) | E2E | 需要完整的浏览器环境来建立 WebSocket 连接 | 带 WebSocket 监听器的 page.evaluate() |
| 支付/结账流程 | E2E | 多步骤、第三方 iframe、真实世界可靠性 | 完整浏览器流程、frameLocator() |
| 引导/向导流程 | E2E | 多步骤,状态跨页面持久化 | 每个向导阶段使用 test.step() |
| 单个控件行为 | 组件测试 | 开关、手风琴菜单、日期选择器、模态框——隔离交互 | 挂载组件,测试打开/关闭/选择 |
| 权限/授权 | API 测试 | 基于角色的访问是后端逻辑;无需 UI 开销即可测试 | 使用不同认证令牌发起请求 |
何时使用 E2E 测试
最适合:
- 产生收入的关键用户流程(结账、注册、订阅)
- 认证和授权流程(登录、SSO、MFA、密码重置)
- 状态在导航间传递的多页面工作流(向导、引导流程)
- 涉及第三方 iframe 的流程(支付控件、嵌入表单)
- 验证整个堆栈是否正确连接的冒烟测试
- 需要多个浏览器上下文的实时协作功能
应避免:
- 测试表单验证的每一种排列组合(使用组件测试)
- UI 只是薄包装层的 CRUD 操作(使用 API 测试)
- 验证单个组件状态(使用组件测试)
- 测试 API 响应结构或错误码(使用 API 测试)
- 在每个断点处测试响应式布局(使用组件测试)
- 仅影响后端的边界情况(使用 API 测试)
TypeScript
import { test, expect } from "@playwright/test"
// E2E:关键结账流程——这证明了完整浏览器的成本是值得的
test.describe("checkout flow", () => {
test.beforeEach(async ({ page }) => {
// 通过 API 预置数据,让 E2E 测试聚焦于流程本身而非数据准备
await page.request.post("/api/test/seed-cart", {
data: { items: [{ sku: "SHOE-001", qty: 1 }] },
})
await page.goto("/cart")
})
test("用有效支付完成购买", async ({ page }) => {
await test.step("review cart", async () => {
await expect(page.getByRole("heading", { name: "Your Cart" })).toBeVisible()
await expect(page.getByText("Running Shoes")).toBeVisible()
await page.getByRole("button", { name: "Proceed to checkout" }).click()
})
await test.step("fill shipping details", async () => {
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Address").fill("123 Main St")
await page.getByLabel("City").fill("Portland")
await page.getByRole("combobox", { name: "State" }).selectOption("OR")
await page.getByLabel("ZIP code").fill("97201")
await page.getByRole("button", { name: "Continue to payment" }).click()
})
await test.step("enter payment", async () => {
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Place order" }).click()
})
await test.step("verify confirmation", async () => {
await page.waitForURL("**/order/confirmation/**")
await expect(page.getByRole("heading", { name: "Order Confirmed" })).toBeVisible()
await expect(page.getByText(/Order #\d+/)).toBeVisible()
})
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("checkout flow", () => {
test.beforeEach(async ({ page }) => {
await page.request.post("/api/test/seed-cart", {
data: { items: [{ sku: "SHOE-001", qty: 1 }] },
})
await page.goto("/cart")
})
test("用有效支付完成购买", async ({ page }) => {
await test.step("review cart", async () => {
await expect(page.getByRole("heading", { name: "Your Cart" })).toBeVisible()
await expect(page.getByText("Running Shoes")).toBeVisible()
await page.getByRole("button", { name: "Proceed to checkout" }).click()
})
await test.step("fill shipping details", async () => {
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Address").fill("123 Main St")
await page.getByLabel("City").fill("Portland")
await page.getByRole("combobox", { name: "State" }).selectOption("OR")
await page.getByLabel("ZIP code").fill("97201")
await page.getByRole("button", { name: "Continue to payment" }).click()
})
await test.step("enter payment", async () => {
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Place order" }).click()
})
await test.step("verify confirmation", async () => {
await page.waitForURL("**/order/confirmation/**")
await expect(page.getByRole("heading", { name: "Order Confirmed" })).toBeVisible()
await expect(page.getByText(/Order #\d+/)).toBeVisible()
})
})
})
何时使用组件测试
最适合:
- 表单验证(必填字段、格式规则、错误消息、字段交互)
- 交互式控件(模态框、下拉菜单、手风琴菜单、日期选择器、标签页)
- 条件渲染(显示/隐藏逻辑、加载状态、空状态、错误状态)
- 按组件的可访问性(ARIA 属性、键盘导航、焦点管理)
- 不同视口下的响应式布局,无需完整应用开销
- 设计系统组件的视觉状态(悬停、聚焦、禁用、选中)
应避免:
- 测试页面间的路由或导航(使用 E2E)
- 需要真实 Cookie、会话或服务器端状态的流程(使用 E2E)
- 数据持久化或 API 契约验证(使用 API 测试)
- 第三方 iframe 交互(使用 E2E)
- 任何需要多页面或多浏览器上下文的内容(使用 E2E)
TypeScript(使用 @playwright/experimental-ct-react 的 React 示例)
import { test, expect } from '@playwright/experimental-ct-react';
import { LoginForm } from '../src/components/LoginForm';
test.describe('LoginForm component', () => {
test('shows validation errors for empty submission', async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} />);
await component.getByRole('button', { name: 'Sign in' }).click();
await expect(component.getByText('Email is required')).toBeVisible();
await expect(component.getByText('Password is required')).toBeVisible();
});
test('shows error for invalid email format', async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} />);
await component.getByLabel('Email').fill('not-an-email');
await component.getByLabel('Password').fill('password123');
await component.getByRole('button', { name: 'Sign in' }).click();
await expect(component.getByText('Enter a valid email address')).toBeVisible();
});
test('calls onSubmit with credentials for valid input', async ({ mount }) => {
const submitted: Array<{ email: string; password: string }> = [];
const component = await mount(
<LoginForm onSubmit={(data) => submitted.push(data)} />
);
await component.getByLabel('Email').fill('jane@example.com');
await component.getByLabel('Password').fill('s3cure!Pass');
await component.getByRole('button', { name: 'Sign in' }).click();
expect(submitted).toHaveLength(1);
expect(submitted[0]).toEqual({
email: 'jane@example.com',
password: 's3cure!Pass',
});
});
test('disables submit button while loading', async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} loading={true} />);
await expect(component.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
});
test('meets accessibility requirements', async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} />);
// 标签与输入框关联
await expect(component.getByRole('textbox', { name: 'Email' })).toBeVisible();
// 错误消息通过 aria-live 区域播报
await component.getByRole('button', { name: 'Sign in' }).click();
await expect(component.getByRole('alert')).toContainText('Email is required');
});
});
JavaScript(使用 @playwright/experimental-ct-react 的 React 示例)
const { test, expect } = require("@playwright/experimental-ct-react")
const { LoginForm } = require("../src/components/LoginForm")
test.describe("LoginForm component", () => {
test("shows validation errors for empty submission", async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} />)
await component.getByRole("button", { name: "Sign in" }).click()
await expect(component.getByText("Email is required")).toBeVisible()
await expect(component.getByText("Password is required")).toBeVisible()
})
test("calls onSubmit with credentials for valid input", async ({ mount }) => {
const submitted = []
const component = await mount(<LoginForm onSubmit={(data) => submitted.push(data)} />)
await component.getByLabel("Email").fill("jane@example.com")
await component.getByLabel("Password").fill("s3cure!Pass")
await component.getByRole("button", { name: "Sign in" }).click()
expect(submitted).toHaveLength(1)
expect(submitted[0]).toEqual({
email: "jane@example.com",
password: "s3cure!Pass",
})
})
test("disables submit button while loading", async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} loading={true} />)
await expect(component.getByRole("button", { name: "Signing in..." })).toBeDisabled()
})
})
何时使用 API 测试
最适合:
- CRUD 操作(创建、读取、更新、删除资源)
- 输入验证和错误响应(400、422 及结构化错误体)
- 权限和授权检查(基于角色的访问、令牌作用域)
- 数据完整性和业务规则(唯一性、引用完整性、计算逻辑)
- API 契约验证(响应结构、头部、分页)
- 通过 UI 复现成本较高的边界情况(限流、并发更新)
- E2E 测试的数据准备与清理(通过 API 预置数据,通过 API 验证)
应避免:
- 测试错误如何在用户界面上展示(使用组件测试)
- 测试浏览器特定行为(Cookie、重定向、导航)
- 验证视觉布局或响应式设计(使用组件测试)
- 需要 JavaScript 执行或 DOM 交互的流程(使用 E2E 或组件测试)
- 第三方 iframe 交互(使用 E2E)
TypeScript
import { test, expect } from "@playwright/test"
test.describe("Users API", () => {
let authToken: string
test.beforeAll(async ({ request }) => {
const response = await request.post("/api/auth/login", {
data: { email: "admin@example.com", password: "admin-pass" },
})
const body = await response.json()
authToken = body.token
})
test("creates a user with valid data", async ({ request }) => {
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
data: {
email: "newuser@example.com",
name: "Jane Doe",
role: "editor",
},
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user).toMatchObject({
email: "newuser@example.com",
name: "Jane Doe",
role: "editor",
})
expect(user).toHaveProperty("id")
expect(user).toHaveProperty("createdAt")
})
test("rejects duplicate email with 409", async ({ request }) => {
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
data: {
email: "admin@example.com", // 已存在
name: "Duplicate",
role: "viewer",
},
})
expect(response.status()).toBe(409)
const error = await response.json()
expect(error.message).toContain("already exists")
})
test("returns 422 for invalid email format", async ({ request }) => {
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
data: {
email: "not-valid",
name: "Bad Email",
role: "viewer",
},
})
expect(response.status()).toBe(422)
const error = await response.json()
expect(error.errors).toContainEqual(expect.objectContaining({ field: "email" }))
})
test("non-admin cannot create users", async ({ request }) => {
// 以非管理员用户身份登录
const loginResponse = await request.post("/api/auth/login", {
data: { email: "viewer@example.com", password: "viewer-pass" },
})
const { token: viewerToken } = await loginResponse.json()
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${viewerToken}` },
data: {
email: "another@example.com",
name: "Unauthorized",
role: "editor",
},
})
expect(response.status()).toBe(403)
})
test("lists users with pagination", async ({ request }) => {
const response = await request.get("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
params: { page: "1", limit: "10" },
})
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.data).toBeInstanceOf(Array)
expect(body.data.length).toBeLessThanOrEqual(10)
expect(body).toHaveProperty("total")
expect(body).toHaveProperty("page", 1)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("Users API", () => {
let authToken
test.beforeAll(async ({ request }) => {
const response = await request.post("/api/auth/login", {
data: { email: "admin@example.com", password: "admin-pass" },
})
const body = await response.json()
authToken = body.token
})
test("creates a user with valid data", async ({ request }) => {
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
data: {
email: "newuser@example.com",
name: "Jane Doe",
role: "editor",
},
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user).toMatchObject({
email: "newuser@example.com",
name: "Jane Doe",
role: "editor",
})
expect(user).toHaveProperty("id")
expect(user).toHaveProperty("createdAt")
})
test("rejects duplicate email with 409", async ({ request }) => {
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${authToken}` },
data: {
email: "admin@example.com",
name: "Duplicate",
role: "viewer",
},
})
expect(response.status()).toBe(409)
const error = await response.json()
expect(error.message).toContain("already exists")
})
test("non-admin cannot create users", async ({ request }) => {
const loginResponse = await request.post("/api/auth/login", {
data: { email: "viewer@example.com", password: "viewer-pass" },
})
const { token: viewerToken } = await loginResponse.json()
const response = await request.post("/api/users", {
headers: { Authorization: `Bearer ${viewerToken}` },
data: {
email: "another@example.com",
name: "Unauthorized",
role: "editor",
},
})
expect(response.status()).toBe(403)
})
})
组合测试类型
最有效的测试套件会分层使用所有三种类型。以下展示它们如何为一个"用户管理"功能协同工作:
第 1 层:API 测试(占测试数量的 60%)
覆盖后端逻辑的每一种排列组合。运行和维护成本低。
tests/api/users.spec.ts
- 用有效数据创建用户(201)
- 拒绝重复邮箱(409)
- 拒绝无效邮箱格式(422)
- 拒绝缺失必填字段(422)
- 非管理员不能创建用户(403)
- 未认证请求返回 401
- 分页列出用户
- 按角色筛选用户
- 更新用户资料
- 软删除用户
- 阻止删除最后一个管理员
第 2 层:组件测试(占测试数量的 30%)
覆盖 UI 组件的每一种视觉状态和交互。
tests/components/UserForm.spec.tsx
- 空提交时显示验证错误
- 无效邮箱显示内联错误
- 加载时禁用提交按钮
- 提交时调用 onSubmit 并传入表单数据
- 成功提交后重置表单
tests/components/UserTable.spec.tsx
- 从 props 渲染用户行
- 无用户时显示空状态
- 处理删除确认模态框
- 点击列标题进行排序
- 以正确颜色显示角色徽章
第 3 层:E2E 测试(占测试数量的 10%)
仅覆盖证明整个堆栈协同工作的关键路径。
tests/e2e/user-management.spec.ts
- 管理员创建用户并在列表中看到该用户
- 管理员编辑用户的角色
- 查看者无法访问用户管理页面
数据对比
对于这一个功能,你可能有:
- 11 个 API 测试——总计约 2 秒运行完成,无需浏览器
- 10 个组件测试——总计约 5 秒运行完成,真实浏览器但无需服务器
- 3 个 E2E 测试——总计约 15 秒运行完成,完整堆栈
总计:24 个测试,约 22 秒。11 个 API 测试捕获大多数回归问题。10 个组件测试捕获 UI 缺陷。3 个 E2E 测试证明各层连接正确。如果 E2E 测试失败而 API 和组件测试通过,你就知道问题出在集成层(路由、状态管理、API 客户端),而不是业务逻辑或 UI 组件。
反模式
| 反模式 | 问题 | 更好的做法 |
|---|---|---|
| 对每个表单验证规则都写 E2E 测试 | 30 秒的浏览器测试,而 API 测试 200ms 就能覆盖 | API 测试验证逻辑,一个组件测试验证错误展示 |
| 没有 API 测试——全是 E2E | 测试套件缓慢、因 UI 时序问题而不稳定、难以诊断失败原因 | API 测试负责数据/逻辑,E2E 仅负责关键路径 |
| 组件测试什么都 mock | 测试通过但应用坏了,因为 mock 与现实脱节 | 只 mock 外部边界;使用 API 测试验证真实契约 |
| 跨层重复断言 | 同样的检查在 API、组件和 E2E 测试中都出现——三倍的维护成本 | 每层只测试它最适合验证的内容 |
| E2E 测试通过 UI 来创建自己的测试数据 | 2 分钟的测试中 90 秒是数据准备;不相关的 UI 变动就会导致测试失败 | 在 beforeEach 中通过 API 调用预置数据,然后测试实际流程 |
| 测试第三方行为 | 测试 Stripe 是否验证卡号(那是 Stripe 的职责) | 在组件/E2E 测试中 mock Stripe;信任其 API 契约 |
| 完全跳过 API 层 | UI 测试发现了缺陷,但你无法判断是前端还是后端的问题 | API 测试隔离后端缺陷;组件测试隔离前端缺陷 |
| 整个功能写一个巨大的 E2E 测试 | 5 分钟的测试中途失败,无法确定原因 | 分解为每个关键路径一个聚焦的 E2E 测试;用 test.step() 提升可读性 |
相关文档
- core/test-organization.md——每种测试类型的文件结构和命名约定
- core/api-testing.md——深入探讨 Playwright 的 HTTP 测试
requestAPI - core/component-testing.md——使用 Playwright CT 设置和编写组件测试
- core/authentication.md——认证流程测试模式(E2E +
storageState复用) - core/when-to-mock.md——何时 mock 网络请求,何时访问真实服务
- pom/pom-vs-fixtures-vs-helpers.md——跨层组织共享测试逻辑