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 挂了通常只能定位到“某段链路”,集成测试挂了能定位到“哪个依赖”。两者都要有,但不能互相替代。

关联