53.4 端到端测试
一句话
E2E 只回答一个问题:用户能不能走完这条关键旅程;它的价值是覆盖服务之间的缝隙,代价是分钟级、天生 flaky。所以设计目标是“少而稳”:3–10 条关键旅程、稳定选择器、确定性等待、独立数据。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “E2E 越多越安全” | 一条 E2E 只走一条路径,覆盖不了分支组合。10 条跑 20 分钟还能忍,500 条跑 16 小时没人等;最后一定被跳过 |
| “E2E 能验证业务规则” | 边界值、权限矩阵、表单校验属于单元/集成层。用 E2E 测这些,等于用分钟级工具做毫秒级判断 |
| “失败了重跑就行” | 重跑掩盖真缺陷,也训练团队忽略红色。单条 flake 率 5%、20 条套件,一次全绿的概率只有 0.95^20 ≈ 35.8%——64% 的流水线会随机红 |
| “选择器能点就行” | CSS/XPath 绑定 DOM 结构,改布局就挂。用 role、label、data-testid 这类面向用户语义的选择器 |
E2E 只测三件事
| ✅ 该测 | ❌ 不该测(放下层) |
|---|---|
| 关键用户旅程:登录 → 搜索 → 下单 → 支付 → 退款 | 单个字段的校验规则、边界值 |
| 跨服务缝隙:订单 → 库存 → 支付 → 通知 | 排序算法、状态机分支组合 |
| 部署后冒烟:健康检查 + 核心 API + 一条主链路 | 内部函数行为、SQL 映射(集成测试) |
| 权限的“一条”主路径(如普通用户不能进后台) | 完整权限矩阵(集成/单元测试) |
判断标准:这条用例挂了,公司是否当天就要处理? 答案是“是”,才配进 E2E;否则往下沉(见 53.1 测试金字塔)。接口字段级的兼容性不要靠 E2E 兜底,用契约测试守(见 53.5 契约测试)。
一条 E2E 的结构:API 造数,UI 只做动作
Given:通过 API/DB 造出前置状态(不是点 20 下 UI)
When :只执行本次要验证的用户动作
Then :断言用户可见结果 + 后端真实状态
用 API 造数的三个理由:快一个数量级、不受 UI 改版影响、失败时能区分“造数失败”和“被测动作失败”。
下面是一条可运行的 API 级 E2E(真 TCP、真路由、真编解码;把内存 map 换成数据库就是完整链路):
package e2e
import (
"encoding/json"
"net/http"
"strconv"
)
type Order struct {
ID int `json:"id"`
SKU string `json:"sku"`
Qty int `json:"qty"`
Total int `json:"total"` // 分
}
// NewAPI 组装真实路由;生产里把内存 map 换成 DB 即可。
func NewAPI() http.Handler {
mux := http.NewServeMux()
orders, next := map[int]Order{}, 0
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
var in struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Qty <= 0 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
next++
o := Order{ID: next, SKU: in.SKU, Qty: in.Qty, Total: 1000 * in.Qty}
orders[o.ID] = o
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(o)
})
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
o, ok := orders[id]
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(o)
})
return mux
}
package e2e
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func TestBuyFlow(t *testing.T) {
srv := httptest.NewServer(NewAPI()) // 真 TCP:路由、编解码、状态码都走一遍
defer srv.Close()
resp, err := http.Post(srv.URL+"/orders", "application/json",
bytes.NewBufferString(`{"sku":"book","qty":2}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: got %d, want 201", resp.StatusCode)
}
var created Order
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
t.Fatal(err)
}
got, err := http.Get(srv.URL + "/orders/" + strconv.Itoa(created.ID))
if err != nil {
t.Fatal(err)
}
defer got.Body.Close()
var order Order
if err := json.NewDecoder(got.Body).Decode(&order); err != nil {
t.Fatal(err)
}
if order.Total != 2000 || order.Qty != 2 {
t.Fatalf("got total=%d qty=%d, want 2000/2", order.Total, order.Qty)
}
}
$ go test -v -run TestBuyFlow
=== RUN TestBuyFlow
--- PASS: TestBuyFlow (0.00s)
PASS
# 把金额改成 999*qty 后
--- FAIL: TestBuyFlow (0.00s)
api_test.go:41: got total=1998 qty=2, want 2000/2
失败信息直接给出“期望 2000、实际 1998”,不用翻日志。E2E 的断言要落在用户可见结果上(金额、状态、库存),不是“某个函数被调用”。
浏览器级 E2E 结构相同,只是把 http.Post 换成页面动作(Playwright 示例,本机未跑过浏览器):
test('buy book', async ({ page, request }) => {
const resp = await request.post('/api/orders', { data: { sku: 'book', qty: 2 } });
const order = await resp.json();
await page.goto(`/orders/${order.id}`);
await page.getByRole('button', { name: '支付' }).click();
await expect(page.getByTestId('order-status')).toHaveText('已支付');
});
稳定性的四根柱子
| 柱子 | 反模式 | 正确做法 |
|---|---|---|
| 选择器 | div:nth-child(3) > span、复制 XPath | getByRole('button', {name:'支付'})、data-testid |
| 等待 | sleep(3000)、waitForTimeout | 等条件:expect(locator).toBeVisible()、等网络响应 |
| 数据 | 共享 staging 账号、依赖上一条用例的数据 | 每条用例 API 造数 + 唯一前缀 + t.Cleanup 清理 |
| 环境 | 手工部署、依赖开发同学本地服务 | 固定镜像/版本 + 健康检查 + 依赖隔离(见 53.3 集成测试) |
🔑 E2E 的第一大 flaky 来源不是产品 bug,是等待。所有“偶发失败”先查有没有固定 sleep、有没有等错元素。
数量与时长:阻断套件 15 分钟封顶
| 套件 | 条数 | 单条 | 总时长 | 放在哪 |
|---|---|---|---|---|
| PR 冒烟 | 3 | 1 min | 3 min | 每个 PR |
| 合并门禁 | 10 | 1.5 min | 15 min | 合并队列 / 主分支 |
| 夜间全量 | 30+ | 2 min | 60 min+ | 夜间,不阻断合并 |
超过 15 分钟的阻断套件要拆:核心 3 条留 PR,其余移到合并后。门禁位置与排队成本见 52.4 流水线设计与耗时优化,不要用“加机器”掩盖套件过大。
生产落地:指名 → 可抄 → 可验 → 可退
指名:浏览器 E2E 用 Playwright(多浏览器、自动等待、trace 回放)或 Cypress(DX 好、单浏览器历史包袱);API E2E 用 Go httptest / Postman/Newman。替代:Selenium 仅在必须兼容旧浏览器时用。
可抄(Playwright 配置,官方语法,本机未跑过):
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: 0, // 重试会掩盖 flake,先隔离再修
use: { trace: 'retain-on-failure', video: 'retain-on-failure' },
reporter: [['html'], ['junit', { outputFile: 'e2e.xml' }]],
});
可验:上面的 Go 用例跑出 PASS;失败时给出期望/实际。浏览器侧至少验三件事:trace 能回放、失败截图/视频存在、CI 里能单独重跑一条(--grep)。
可退:
- 新用例不稳定:标
test.fixme/quarantine,开 ticket + owner,限期 7 天;不要直接删。 - 环境挂了:冒烟套件失败先看健康检查,区分“产品坏”与“环境坏”。
- 发布事故:E2E 冒烟是发布门禁的一部分,回滚流程见 55.1 发布策略;事故复盘见 82.4 发布事故(待补)。
反模式
| 反模式 | 后果 |
|---|---|
| 用 E2E 测表单校验/边界值 | 分钟级反馈 + 高 flaky,团队最终绕过 |
| 共享测试账号/固定数据 | 并行冲突、顺序依赖、脏数据 |
sleep 等元素 | 慢且不稳:网络快时白等,慢时仍失败 |
| CSS/XPath 绑 DOM 结构 | 改样式就红,维护成本超过收益 |
| 失败自动重跑 3 次 | 真缺陷被重跑掩盖,flake 率继续上升 |
| 只在发布前跑 | 问题在最后一天才暴露,回滚成本最高 |
| 只断言页面文字 | 后端状态错了页面也可能“看起来对” |
| 环境手工准备 | 不可复现,新人跑不起来 |
自检
Q1 团队有 500 条 E2E、跑 16 小时,每次发布前通宵跑。问题出在哪?
▸ 答案:把 E2E 当成了“全量验证”工具。E2E 的职责是覆盖关键旅程和服务缝隙,不是穷举分支。500 条里绝大多数应该下沉到单元/集成测试;保留 10 条左右主链路 + 3 条冒烟,阻断套件压到 15 分钟内。判断标准是“这条挂了公司当天是否处理”,不是“它能不能跑”。
Q2 一条 E2E 在 CI 偶发失败,本地重跑又过了。第一步做什么?
▸ 答案:先看 trace/视频/网络日志,确认失败发生在哪一步;再查四根柱子:有没有固定 sleep、选择器是否绑结构、数据是否共享、环境是否稳定。不要先加重试——重试只是把 flake 藏起来,下一次还会红。无法当场定位的用例进 quarantine(不阻断、有人认领、限期修),同时保留 canary 确认主链路仍被覆盖。
Q3 为什么强调“API 造数,UI 只做动作”?
▸ 答案:UI 造数慢(点 20 下)、脆(改版就挂)、且失败时无法区分“造数失败”还是“被测动作失败”。API 造数快一个数量级,前置状态可精确控制,失败信息也能分层:造数 4xx 是测试数据问题,动作失败才是产品问题。只有“造数本身就是要测的流程”时,才用 UI 造数。
Q4 单条 E2E flake 率 5%,20 条套件一次全绿的概率是多少?这说明什么?
▸ 答案:0.95^20 ≈ 35.8%,也就是 64% 的概率至少红一条。这说明“重跑到绿”不是解法:即使每条都不修,团队也会习惯性重跑,红色失去信号。正确做法是把 flake 当缺陷管理:隔离、定位、修根因,并把重试率作为健康度指标(见 52.4 流水线设计与耗时优化 里关于抖动的部分)。
Q5 E2E 和集成测试的边界在哪?
▸ 答案:集成测试验证“模块与真实依赖能否合上”(DB、缓存、MQ),入口是代码/接口;E2E 验证“用户从真实入口走完一条旅程”,入口是 HTTP/浏览器,覆盖部署拓扑、网关、鉴权、前后端契约。E2E 挂了通常只能定位到“某段链路”,集成测试挂了能定位到“哪个依赖”。两者都要有,但不能互相替代。
关联
- 前置:53.1 测试金字塔、53.3 集成测试
- 并列:53.5 契约测试
- 下游:52.4 流水线设计与耗时优化、55.1 发布策略
- 待补:82.4 发布事故