# 测试架构:E2E 测试 vs 组件测试 vs API 测试 > **使用时机**:当需要为一个功能决定编写哪种测试时。在写任何测试之前,先问:"什么是最便宜的、能让我对功能有信心的测试?" ## 快速回答 大多数团队写了太多 E2E 测试。默认采用 **testing trophy(测试奖杯)** 方法: 1. **API 测试**——用于业务逻辑、数据验证和权限(最快、最稳定) 2. **组件测试**——用于隔离的 UI 行为、表单验证和交互式控件(快速、聚焦) 3. **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** ```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** ```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 示例) ```typescript 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( {}} />); 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( {}} />); 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( 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( {}} loading={true} />); await expect(component.getByRole('button', { name: 'Signing in...' })).toBeDisabled(); }); test('meets accessibility requirements', async ({ mount }) => { const component = await mount( {}} />); // 标签与输入框关联 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 示例) ```javascript 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( {}} />) 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( 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( {}} 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** ```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** ```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](test-organization.md)——每种测试类型的文件结构和命名约定 - [core/api-testing.md](api-testing.md)——深入探讨 Playwright 的 HTTP 测试 `request` API - [core/component-testing.md](component-testing.md)——使用 Playwright CT 设置和编写组件测试 - [core/authentication.md](authentication.md)——认证流程测试模式(E2E + `storageState` 复用) - [core/when-to-mock.md](when-to-mock.md)——何时 mock 网络请求,何时访问真实服务 - [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md)——跨层组织共享测试逻辑