580 lines
25 KiB
Markdown
580 lines
25 KiB
Markdown
# 测试架构: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(<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 示例)
|
||
|
||
```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(<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**
|
||
|
||
```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)——跨层组织共享测试逻辑
|