Files
2026-07-13 21:36:47 +08:00

25 KiB
Raw Permalink Blame History

测试架构: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

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() 提升可读性

相关文档