---
title: "9.2 API 测试与 E2E 测试"
description: "从评分接口到完整用户流程——API 测试实战、Playwright E2E 测试、Flaky 测试排查、日志阅读"
chapter: "第九章"
---

# 9.2 API 测试与 E2E 测试

> **本节目标**：掌握 API 测试的覆盖思路和 E2E 测试的实战方法，学会排查 Flaky 测试和阅读测试日志。

---

## 为什么先测 API

小明决定开始写测试。但从哪里开始？他的 app 有前端页面、有后端接口、有数据库操作——测哪个？

老师傅说："先测地基——API 接口。"

原因很实际。小明的评分页面报错了，他打开浏览器一看，页面上显示"评分提交失败"。问题出在哪？可能是前端的提交按钮没有正确调用接口，可能是接口本身返回了错误数据，也可能是数据库写入失败。如果没有 API 测试，他得打开浏览器、登录、找到电影、点评分、看结果——每次调试都要走一遍完整流程，才能判断"到底是前端的问题还是后端的问题"。

但如果有 API 测试，他可以直接发一个请求到 `POST /api/movies/42/rate`，看返回值。接口返回 200 和正确数据？那问题在前端。接口返回 500？那问题在后端。**API 测试帮你把问题定位到"前端还是后端"这个层面**，不用每次都从浏览器开始排查。

还有一个重要原因：**API 接口比 UI 稳定得多**。小明的评分页面可能经常改——按钮换位置、改样式、加动画。每次 UI 变动，依赖 UI 元素的测试都可能失败，即使功能本身没问题。但 API 接口的契约相对稳定——"发这个请求，返回这个数据"不会因为按钮换了颜色就变。API 测试写一次，能用很久；UI 测试写一次，可能下周就要改。

就像检查汽车：你可以单独测发动机能不能启动（API 测试），也可以开上路跑一圈看整体表现（E2E 测试）。发动机测试快、稳定、容易定位问题；路测全面但耗时，而且路况、天气都可能影响结果。大部分问题在发动机测试阶段就能发现。

## 一个接口该测什么

小明的评分接口是 `POST /api/movies/:id/rate`，接收 `{ rating: 4 }` 这样的请求体，把评分写入数据库，返回更新后的均分。这个接口该测哪些场景？

**正常路径**：已登录用户提交合法评分（1-5 之间的整数），期望返回 200 和正确的均分。这是最基本的——功能本身能不能用。

**参数校验**：用户提交了评分 0 或者 6（超出 1-5 的范围），期望返回 400 和错误提示。用户提交了一个字符串 `"abc"` 而不是数字，期望返回 400。这测的是"接口能不能拒绝不合法的输入"——如果接口不校验，数据库里就会出现脏数据。

**权限控制**：未登录用户提交评分，期望返回 401。这测的是"接口有没有检查身份"——第八章你已经加了认证，这里验证它确实在工作。

**边界情况**：同一个用户对同一部电影重复评分——应该更新旧评分还是拒绝？电影 ID 不存在（比如 `/api/movies/99999/rate`），期望返回 404。这些是业务特有的场景。如果你已经在代码里实现了重复评分的处理逻辑，AI 读代码时会看到并生成对应测试；但如果这个规则还只在你脑子里（你打算实现但还没写），就需要你主动提出来。

**一个接口至少覆盖这四类场景**：正常、校验、权限、边界。这不是教条，而是一种思维方式——每次写测试时问自己：正常情况能用吗？错误输入会被拦住吗？没权限的人能访问吗？有没有我知道但 AI 不知道的特殊情况？

<APITestScenarios />

<details>
<summary>好奇的话展开看看：评分接口的完整测试</summary>

```typescript
import { describe, test, expect, beforeEach } from 'vitest'

describe('POST /api/movies/:id/rate', () => {
  // 正常路径
  test('已登录用户提交合法评分', async () => {
    const res = await request(app)
      .post('/api/movies/42/rate')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ rating: 4 })

    expect(res.status).toBe(200)
    expect(res.body.averageRating).toBeCloseTo(4.0)
  })

  // 参数校验
  test('评分超出范围返回 400', async () => {
    const res = await request(app)
      .post('/api/movies/42/rate')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ rating: 6 })

    expect(res.status).toBe(400)
  })

  // 权限控制
  test('未登录用户返回 401', async () => {
    const res = await request(app)
      .post('/api/movies/42/rate')
      .send({ rating: 4 })

    expect(res.status).toBe(401)
  })

  // 边界情况
  test('重复评分更新旧记录', async () => {
    // 第一次评分
    await request(app)
      .post('/api/movies/42/rate')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ rating: 3 })

    // 第二次评分（同一用户同一电影）
    const res = await request(app)
      .post('/api/movies/42/rate')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ rating: 5 })

    expect(res.status).toBe(200)
    // 应该是更新，不是新增
    expect(res.body.averageRating).toBe(5)
  })

  test('电影不存在返回 404', async () => {
    const res = await request(app)
      .post('/api/movies/99999/rate')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ rating: 4 })

    expect(res.status).toBe(404)
  })
})
```

</details>

## 让 AI 帮你生成 API 测试

你已经习惯了让 Claude Code 分析代码库并生成代码，测试也一样。把你的 API 文件夹引用给它，告诉它为每个接口生成测试：

> 跟 AI 说："阅读 `app/api` 文件夹下的所有路由，为每个 API 生成 Vitest 测试。每个接口覆盖：正常请求（200）、参数错误（400）、未授权（401）。"

它会遍历你的接口文件，分析每个路由的参数、返回值和中间件，生成对应的测试代码。但你要审查生成的结果——不是看语法（AI 不太会写错语法），而是看覆盖度。上面说的"四类场景"，AI 读代码后通常能覆盖已实现的逻辑对应的场景。你需要关注的是：有没有你计划实现但还没写的业务规则？有没有代码里没有显式处理的边界情况（比如并发提交、极端数据量）？这些是你作为业务理解者需要补充的。

还有一个实用技巧：怎么验证测试真的在"测东西"而不是走过场？试着手动在业务代码里引入一个 bug（比如把评分范围校验从 `1-5` 改成 `1-10`），然后跑测试。如果测试没有报红，说明它没有真正验证这个约束——你需要加一个针对范围边界的断言。这种"故意破坏代码看测试能不能抓住"的方法叫变异测试，是检验测试质量的好手段。

## 从接口到界面：E2E 测试

小明的 API 测试全过了。他松了口气，觉得评分功能应该没问题了。结果朋友反馈："点了评分按钮没反应。"

他去查——API 接口本身没问题，直接发请求能正常返回。问题出在前端：评分按钮的点击事件根本没有调用 API。小明在重构组件时把提交逻辑移到了另一个文件，但忘了在新组件里引入。TypeScript 编译没报错（函数签名是对的，只是没被调用），页面也能正常渲染（按钮在那里，只是点了没反应）。API 测试测不到这个问题，因为 API 测试绕过了前端，直接发请求。

这就是 E2E 测试的价值：它测的是**用户的完整体验**——从打开页面、点击按钮、到看到结果。它不关心"API 能不能用"或"函数返回值对不对"，它关心的是"用户能不能完成他想做的事"。

**Playwright** 是目前最主流的 E2E 测试工具，微软开源。它能自动打开浏览器（Chrome、Firefox、Safari 都支持），模拟用户的点击、输入、滚动等操作，还能截图和录屏。你可以把它理解为一个"机器人用户"——它按照你写的脚本，像真人一样操作你的应用，然后检查结果是否符合预期。

Playwright 有一个特别实用的功能：**录制模式**。你不需要从零开始写测试脚本，可以让 Playwright 录制你的操作——你在浏览器里点哪里、输入什么，它自动生成对应的测试代码。生成的代码可能需要微调（比如选择器不够稳定），但作为起点比从零写快得多。运行 `npx playwright codegen http://localhost:3000` 就能启动录制模式。

小明的第一个 E2E 测试：用户登录后给电影打分。

<details>
<summary>好奇的话展开看看：Playwright 测试代码</summary>

```typescript
import { test, expect } from '@playwright/test'

test('用户给电影打分', async ({ page }) => {
  // 登录
  await page.goto('/login')
  await page.fill('[name="email"]', 'ming@example.com')
  await page.fill('[name="password"]', 'password123')
  await page.click('button[type="submit"]')

  // 等待登录完成，跳转到首页
  await page.waitForURL('/')

  // 进入电影详情页
  await page.click('text=星际穿越')
  await page.waitForURL('/movies/**')

  // 点击 4 星
  await page.click('[data-testid="star-4"]')
  await page.click('[data-testid="submit-rating"]')

  // 验证均分更新
  await expect(page.locator('.average-rating')).toContainText('4')
})
```

</details>

注意代码里的 `waitForURL` 和 `waitForURL('/movies/**')`——这些是**显式等待**，告诉 Playwright "等到页面跳转完成再继续"。如果不等，脚本可能在页面还没加载完就去找元素，找不到就报错。这是 E2E 测试最常见的坑之一，下面会详细讲。

## Flaky 测试——测试界的"玄学"

<FlakyTestAnalysis />

小明写了第一个 E2E 测试，跑了三次：通过、通过、失败。同样的代码，同样的环境，为什么结果不一样？

这就是 **Flaky 测试**——有时通过有时失败的测试。就像自动售货机偶尔吞钱不出货——不是你投错了硬币，是机器本身不稳定。Flaky 测试是 E2E 测试最让人头疼的问题，几乎每个写 E2E 测试的人都会遇到。

为什么会 Flaky？原因通常是这几种：

**异步操作的时序问题**。小明点了"提交评分"按钮，测试脚本立刻去检查页面上的均分有没有更新。但评分提交是异步的——请求发出去了，服务器还没返回，页面还没更新，测试就已经断言了。本地网络快的时候，请求瞬间返回，测试通过；网络稍微慢一点，请求还没回来，测试就失败了。解决方法是**显式等待**：不要在操作后立刻断言，而是等到目标元素出现或更新后再断言。Playwright 的 `expect(locator).toContainText()` 默认会等待一段时间（5 秒），但如果你的操作特别慢（比如涉及文件上传或复杂计算），可能需要手动加长超时时间。

**数据竞争**。小明写了两个测试：测试 A 给电影 42 打了 5 分，测试 B 检查电影 42 的均分是不是 0（假设没人评过分）。如果测试 A 先跑，测试 B 就会失败——因为测试 A 留下的数据影响了测试 B。解决方法是**数据隔离**：每个测试独立准备自己需要的数据，测试结束后清理干净。不要让测试之间共享状态。

**环境差异**。本地开发机性能好、网络快，测试跑得顺畅。但 CI 环境（GitHub Actions 的虚拟机）可能性能差一些、网络延迟高一些，同样的等待时间在本地够用，在 CI 上就不够了。解决方法是给断言设置**合理的超时时间**，不要假设操作会瞬间完成。

Flaky 测试不能忽略。"偶尔失败"意味着你永远不确定测试结果是否可信——测试通过了，是真的没问题，还是恰好这次没触发 Flaky？安全网上有个洞，你就不敢信任它。遇到 Flaky 测试，要么修好它（找到不稳定的原因并解决），要么暂时跳过它（标记为 `skip`）并记录原因，但不要让它一直在那里"偶尔红"。

怎么判断一个测试是 Flaky 还是真的有 bug？一个简单的方法：**连续跑 5 次**。如果 5 次都失败，那大概率是真 bug；如果 5 次里有几次过几次不过，那就是 Flaky。Playwright 支持 `--repeat-each=5` 参数，让每个测试重复跑 5 次，方便你快速判断。

修 Flaky 测试的核心原则是：**不要用 `sleep` 来"等一等"**。`await page.waitForTimeout(2000)`（等 2 秒）看起来能解决问题，但它只是把问题藏起来了——本地 2 秒够用，CI 上可能不够；今天够用，数据量大了以后可能不够。正确的做法是用**条件等待**：`await page.waitForSelector('.average-rating')`（等到这个元素出现）、`await expect(locator).toContainText('4')`（等到文本变成预期值）。条件等待会在条件满足的瞬间继续执行，既不会等太久（浪费时间），也不会等太短（导致 Flaky）。

> 跟 AI 说："这个 E2E 测试有时候通过有时候失败，帮我分析原因并修复。错误信息是：[粘贴错误日志]"

## 测试失败了怎么看日志

小明的 E2E 测试挂了，终端输出了一堆信息。他看着满屏的红色文字，不知道从哪里开始。老师傅说："别慌，三步定位。"

**第一步：看错误类型**。测试失败的错误信息通常分几种：

`Timeout waiting for selector "[data-testid='submit-rating']"` ——这是**等待超时**，意思是 Playwright 在规定时间内没有找到这个元素。可能的原因：页面还没加载完、元素的 `data-testid` 改了、前面的操作（比如登录）失败了导致页面停在了错误的地方。

`Expected "4.0" but received "3.5"` ——这是**断言失败**，元素找到了，但值不对。可能的原因：计算逻辑有 bug、测试数据没有正确准备（比如数据库里已经有别的评分记录影响了均分）。

`Error: net::ERR_CONNECTION_REFUSED` ——这是**网络错误**，通常意味着后端服务没有启动，或者端口不对。

**第二步：看截图**。Playwright 会自动保存测试失败时的页面截图，默认在 `test-results/` 目录下。打开截图一看就知道页面当时是什么状态——是停在登录页（说明登录失败了）？是显示了错误提示（说明 API 返回了错误）？还是页面一片空白（说明前端渲染崩了）？截图比日志直观得多，很多时候一张截图就能定位问题。

**第三步：看网络请求**。如果截图看不出问题，就要看测试过程中的网络请求。Playwright 可以配置 trace（追踪），记录测试过程中的每一个网络请求和响应。打开 trace 文件，找到评分提交的那个请求——它返回了 200 还是 500？返回的数据是什么？如果 API 返回了 500，那问题在后端；如果 API 返回了正确数据但页面没更新，那问题在前端的渲染逻辑。

<details>
<summary>好奇的话展开看看：开启 Playwright Trace</summary>

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    // 只在测试失败时保存 trace
    trace: 'on-first-retry',
    // 失败时自动截图
    screenshot: 'only-on-failure',
  },
})
```

查看 trace：

```bash
npx playwright show-trace test-results/xxx/trace.zip
```

这会打开一个可视化界面，你可以逐步回放测试过程，看到每一步操作时页面的状态、网络请求和控制台日志。

</details>

**错误信息 → 截图 → 网络请求**，三步走下来，绝大部分测试失败都能定位到原因。如果还是找不到，把错误日志和截图一起丢给 AI，让它帮你分析。

## 什么时候用 E2E，什么时候用 API 测试

小明现在有了 API 测试和 E2E 测试两种工具，但他不确定什么时候该用哪个。老师傅给了一个简单的判断标准：

**API 测试覆盖"数据对不对"**——接口返回的状态码、数据格式、业务逻辑是否正确。大部分功能的核心逻辑都可以通过 API 测试验证。写起来快，跑起来快，维护成本低。

**E2E 测试覆盖"用户能不能用"**——页面能不能打开、按钮能不能点、流程能不能走通。只用在最关键的用户流程上：注册登录、核心业务操作（评分、搜索）、支付流程。不要给每个页面都写 E2E 测试——维护成本会爆炸。

小明的"个人豆瓣"，合理的测试分配是：所有 API 接口都有集成测试（覆盖四类场景），E2E 测试只写两三个——"用户注册登录"、"搜索电影并查看详情"、"给电影评分"。这三个流程覆盖了最核心的用户体验，其他的交给 API 测试就够了。

一个常见的误区是"E2E 测试更真实，所以更好"。真实不等于更好——E2E 测试确实最接近用户体验，但它的维护成本也最高。小明的评分页面改了一次布局，所有涉及评分的 E2E 测试都要更新选择器；但 API 测试完全不受影响。如果你发现自己花在维护 E2E 测试上的时间比写新功能还多，那说明 E2E 测试写太多了——把一部分逻辑下沉到 API 测试层。

另一个判断标准：**如果一个 bug 只能通过 E2E 测试发现（比如按钮没绑事件、页面跳转错误），那就用 E2E 测试；如果一个 bug 通过 API 测试就能发现（比如接口返回错误数据、权限没校验），就用 API 测试**。能用更轻量的方式验证的，就不要用更重的方式。

---

::: tip 本节核心要点
- 先测 API：快、稳定、容易定位问题。一个接口至少测四类场景：正常、校验、权限、边界
- E2E 测试测"用户体验"：只覆盖最关键的流程，不要过度使用
- Flaky 测试三大原因：异步时序、数据竞争、环境差异。用显式等待、数据隔离、合理超时来解决
- 测试失败三步定位：错误信息 → 截图 → 网络请求
:::

::: info 下一步
有了测试，但经常忘了跑？去 [自动化工作流](./03-automation-workflow.md)——让 Git Hooks 和 GitHub Actions 帮你记住"每次都要检查"。
:::
