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 不知道的特殊情况?
POST /api/ratings好奇的话展开看看:评分接口的完整测试
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)
})
})让 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 测试:用户登录后给电影打分。
好奇的话展开看看:Playwright 测试代码
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')
})注意代码里的 waitForURL 和 waitForURL('/movies/**')——这些是显式等待,告诉 Playwright "等到页面跳转完成再继续"。如果不等,脚本可能在页面还没加载完就去找元素,找不到就报错。这是 E2E 测试最常见的坑之一,下面会详细讲。
Flaky 测试——测试界的"玄学"
小明写了第一个 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 返回了正确数据但页面没更新,那问题在前端的渲染逻辑。
好奇的话展开看看:开启 Playwright Trace
// playwright.config.ts
export default defineConfig({
use: {
// 只在测试失败时保存 trace
trace: 'on-first-retry',
// 失败时自动截图
screenshot: 'only-on-failure',
},
})查看 trace:
npx playwright show-trace test-results/xxx/trace.zip这会打开一个可视化界面,你可以逐步回放测试过程,看到每一步操作时页面的状态、网络请求和控制台日志。
错误信息 → 截图 → 网络请求,三步走下来,绝大部分测试失败都能定位到原因。如果还是找不到,把错误日志和截图一起丢给 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 测试。能用更轻量的方式验证的,就不要用更重的方式。
本节核心要点
- 先测 API:快、稳定、容易定位问题。一个接口至少测四类场景:正常、校验、权限、边界
- E2E 测试测"用户体验":只覆盖最关键的流程,不要过度使用
- Flaky 测试三大原因:异步时序、数据竞争、环境差异。用显式等待、数据隔离、合理超时来解决
- 测试失败三步定位:错误信息 → 截图 → 网络请求
下一步
有了测试,但经常忘了跑?去 自动化工作流——让 Git Hooks 和 GitHub Actions 帮你记住"每次都要检查"。
