单测全绿,注册后的第一步就断了
「回声」的账号服务由两个包组成:account 负责注册、登录和会话,bff 负责把登录结果转换成各端可用的响应。两个包各有完整的单元测试,CI 一直是绿色。
一次客户端联调发现,用户注册成功后登录,App 拿不到登录态。抓包看到登录响应里是:
{"user_id":"u-1042","token":"sess-0007"}
而 BFF 读取的是 access_token。BFF 的单元测试用固定 fixture,fixture 里恰好写的是 access_token;账号包的测试解码自己定义的响应结构,只检查 token 非空。两边各自都“验证了自己的契约”,但没有人验证这两个契约是同一个。
这类问题用单测很难发现,因为单测的边界是包,而故障发生在包的接缝处。补一个端到端用例,从“用户注册”开始,按真实顺序走到“旧设备被踢出”,问题会在第一分钟暴露。
用例从用户旅程来,不从接口清单来
接口清单回答“有哪些 URL”,用户旅程回答“用户要完成什么”。两者的覆盖方式不同:按接口清单写用例,容易得到一堆互不相关的 200;按旅程写用例,才能发现上一步的输出是不是下一步的输入。
以「回声」的账号主旅程为例:
| 旅程步骤 | 请求 | 期望状态 | 还要断言的业务结果 |
|---|---|---|---|
| 注册 | POST /register | 201 | 账号可立即登录,重复注册同一邮箱被拒 |
| 登录 | POST /login | 200 | 返回的 token 能被后续请求使用,字段名与 BFF 读取一致 |
| 改资料 | PATCH /me | 200 | 再次读取能看到新昵称,旧昵称不再出现 |
| 踢出旧设备 | POST /sessions/revoke-others | 200 | 旧 token 再请求返回 401,新 token 仍可用 |
状态码只是第一层。注册返回 201 但账号没落库,登录返回 200 但 token 是空字符串,改资料返回 200 但读回来还是旧值,这些都能骗过“只看状态码”的测试。端到端用例必须把每一步的业务结果带进下一步,让后一步成为前一步的断言。
用例的起点要尽量靠近用户,终点要落在用户能感知的结果上。只测 POST /login 返回 200,测不出 BFF 字段名不一致;只测 BFF 的聚合函数,测不出账号服务实际序列化了什么。把两者放进同一条旅程,接缝才会被覆盖。
用例之间要隔离。共享一个账号会让“重复注册被拒”和“正常注册成功”互相污染;共享一个数据库快照会让并行执行的结果依赖顺序。每个场景自己准备前置状态、自己清理,失败时才能单独重跑。
失败场景:字段名不一致,测试各自为政
把那次故障拆开看,三个“绿色”都很合理,又都没有覆盖接缝。
账号包的测试启动自己的 handler,注册后断言响应里的 token 非空。它不知道 BFF 存在,也没有理由知道。BFF 的测试用 httptest 构造一个假的账号响应,fixture 里写的是 access_token,因为写 fixture 的人按客户端的旧约定填的。两个测试都通过了,真实链路却把 token 交给了期待 access_token 的代码。
更隐蔽的是,修复方式可能只是把 BFF 改成读 token,但客户端还在读 access_token。如果端到端用例只覆盖到 BFF,不覆盖客户端契约,问题会从服务端接缝转移到客户端接缝。真正要固定的是“登录响应里到底有哪个字段、谁消费它”,这个约定必须有一条从注册到登录再到受保护请求的完整路径来证明。
后果不只是登录失败。用户会重复注册、重复登录,产生重复账号和多余的会话;客服看到的是“用户说登不上”,日志里每个服务都返回 200;排查时两个团队各自证明自己没问题,时间花在互相甩锅上。
修复通常包含三件事:统一登录响应的字段名并写进契约测试;让 BFF 的 fixture 从账号服务的真实响应生成,而不是手写;增加一条端到端场景,断言登录返回的 token 能直接用于下一次请求。第三条是这次故障唯一能在合并前拦住它的测试。
用 Run 把旅程变成可执行断言
下面的运行器把场景定义成有序步骤,用 net/http/httptest 构造真实请求并调用处理器。状态码不符会记录失败并继续执行后续步骤,处理器 panic 会被收敛成一条失败记录,整个 Run 不会把测试进程带崩。
// file: internal/baseline/e2e.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
// Step 是一个端到端用例中的一次请求。Body 为空表示没有请求体,
// WantStatus 必须显式填写,避免“忘了断言”被当成通过。
type Step struct {
Name string
Method string
Path string
Body string
WantStatus int
}
// Scenario 是一条用户旅程。步骤按数组顺序执行,后面的步骤可以依赖
// 前面步骤留下的服务端状态。
type Scenario struct {
Name string
Steps []Step
}
// Result 汇总一次执行。Passed 是成功步骤数,Failed 保留每个失败步骤的
// 可读原因,顺序与执行顺序一致。
type Result struct {
Passed int
Failed []string
}
// Run 用真实 HTTP 处理器执行场景。状态码不符只记录失败并继续,
// 这样一次运行能看到所有问题,而不是修一个再跑一次才发现下一个。
func Run(sc Scenario, h http.Handler) (Result, error) {
var out Result
if len(sc.Steps) == 0 {
return out, fmt.Errorf("baseline: scenario %q has no steps", sc.Name)
}
if h == nil {
return out, fmt.Errorf("baseline: scenario %q has nil handler", sc.Name)
}
for _, step := range sc.Steps {
if err := runStep(step, h); err != nil {
out.Failed = append(out.Failed, err.Error())
continue
}
out.Passed++
}
return out, nil
}
// runStep 构造请求并调用处理器。处理器 panic 会被收敛成一条失败记录,
// 不让一个坏步骤中断整条旅程。
func runStep(step Step, h http.Handler) (err error) {
label := strings.TrimSpace(step.Name)
if label == "" {
label = strings.TrimSpace(step.Method + " " + step.Path)
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%s: handler panic: %v", label, r)
}
}()
method := step.Method
if method == "" {
method = http.MethodGet
}
if !validToken(method) {
return fmt.Errorf("%s: invalid method %q", label, method)
}
if !strings.HasPrefix(step.Path, "/") {
return fmt.Errorf("%s: path %q must start with /", label, step.Path)
}
req := httptest.NewRequest(method, step.Path, strings.NewReader(step.Body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != step.WantStatus {
return fmt.Errorf("%s: want status %d, got %d", label, step.WantStatus, rec.Code)
}
return nil
}
// validToken 检查 HTTP 方法是否由 RFC 9110 的 tchar 组成。
// 提前拒绝非法方法,比让 httptest 在构造请求时 panic 更容易定位。
func validToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
continue
}
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
continue
}
return false
}
return true
}
Run 只在场景非法时返回 error(没有步骤、处理器为空),单个步骤失败进入 Result.Failed。这个区分很重要:场景非法是测试配置错误,应该让整个测试失败;步骤失败是被测系统的问题,应该收集全部证据后统一判断。
runStep 的 defer recover 只包住一次请求。处理器 panic 后,后续步骤仍然执行,Failed 里会写下步骤名和 panic 值。生产环境的 recover 还要记录堆栈并返回 500,这里的目的是让测试报告完整,不是替代服务的错误处理。
validToken 和路径前缀检查是防御性的。httptest.NewRequest 在目标无法解析时会 panic,与其让测试框架抛出一段难读的堆栈,不如在进入请求前返回一条带步骤名的错误。
测试:继续执行、panic 与非法输入
端到端运行器本身的测试,要证明它不会因为一个失败步骤就丢掉后面的证据。
// file: internal/baseline/e2e_test.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"net/http"
"strings"
"testing"
)
func TestRunPassesRealRequests(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/register":
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusCreated)
case "/login":
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
sc := Scenario{Name: "register-login", Steps: []Step{
{Name: "register", Method: http.MethodPost, Path: "/register", Body: `{"email":"a@b.c"}`, WantStatus: http.StatusCreated},
{Name: "login", Method: http.MethodPost, Path: "/login", WantStatus: http.StatusOK},
{Name: "unknown", Path: "/nope", WantStatus: http.StatusNotFound},
}}
res, err := Run(sc, h)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != 3 || len(res.Failed) != 0 {
t.Fatalf("Run() = %+v, want 3 passed and no failures", res)
}
}
func TestRunContinuesAfterFailure(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/boom" {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
sc := Scenario{Name: "keep-going", Steps: []Step{
{Name: "first", Path: "/ok", WantStatus: http.StatusOK},
{Name: "broken", Path: "/boom", WantStatus: http.StatusOK},
{Name: "last", Path: "/ok", WantStatus: http.StatusOK},
}}
res, err := Run(sc, h)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != 2 {
t.Fatalf("Passed = %d, want 2", res.Passed)
}
if len(res.Failed) != 1 || !strings.Contains(res.Failed[0], "broken") {
t.Fatalf("Failed = %v, want one entry for broken", res.Failed)
}
if !strings.Contains(res.Failed[0], "want status 200, got 500") {
t.Fatalf("Failed[0] = %q, want status detail", res.Failed[0])
}
}
func TestRunRejectsEmptyScenario(t *testing.T) {
res, err := Run(Scenario{Name: "empty"}, http.NotFoundHandler())
if err == nil || !strings.Contains(err.Error(), "no steps") {
t.Fatalf("Run() error = %v, want no steps", err)
}
if res.Passed != 0 || len(res.Failed) != 0 {
t.Fatalf("Run() = %+v, want zero result", res)
}
}
func TestRunRejectsNilHandler(t *testing.T) {
sc := Scenario{Name: "nil", Steps: []Step{{Path: "/", WantStatus: http.StatusOK}}}
if _, err := Run(sc, nil); err == nil || !strings.Contains(err.Error(), "nil handler") {
t.Fatalf("Run() error = %v, want nil handler", err)
}
}
func TestRunRecoversHandlerPanic(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/panic" {
panic("database handle is nil")
}
w.WriteHeader(http.StatusOK)
})
sc := Scenario{Name: "panic", Steps: []Step{
{Name: "explode", Path: "/panic", WantStatus: http.StatusOK},
{Name: "survivor", Path: "/ok", WantStatus: http.StatusOK},
}}
res, err := Run(sc, h)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != 1 {
t.Fatalf("Passed = %d, want 1", res.Passed)
}
if len(res.Failed) != 1 || !strings.Contains(res.Failed[0], "handler panic") {
t.Fatalf("Failed = %v, want panic record", res.Failed)
}
}
func TestRunRejectsInvalidPathWithoutPanic(t *testing.T) {
sc := Scenario{Name: "bad-path", Steps: []Step{
{Name: "relative", Path: "register", WantStatus: http.StatusOK},
}}
res, err := Run(sc, http.NotFoundHandler())
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != 0 || len(res.Failed) != 1 {
t.Fatalf("Run() = %+v, want one failure", res)
}
if !strings.Contains(res.Failed[0], "must start with /") {
t.Fatalf("Failed[0] = %q, want path rule", res.Failed[0])
}
}
TestRunContinuesAfterFailure 是核心:三个步骤中间失败一个,Passed 必须是 2,Failed 必须只有一条,而且这条要同时包含步骤名和状态码差异。只断言“有失败”不够,报告里没有步骤名,值班的人还得自己猜是哪一步。
TestRunRecoversHandlerPanic 证明 panic 不会穿出 Run,并且 panic 之后的步骤还能继续。TestRunRejectsInvalidPathWithoutPanic 则证明非法输入走的是“失败记录”,不是进程崩溃。
这些测试仍然不覆盖数据库事务、真实网络、外部身份提供商和 token 过期。端到端运行器的价值是把 HTTP 接缝固定住;更下游的集成测试和预发环境演练不能省。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/baseline/ -count=1 -v -run 'TestRun'
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRunPassesRealRequests
--- PASS: TestRunPassesRealRequests (0.00s)
=== RUN TestRunContinuesAfterFailure
--- PASS: TestRunContinuesAfterFailure (0.00s)
=== RUN TestRunRejectsEmptyScenario
--- PASS: TestRunRejectsEmptyScenario (0.00s)
=== RUN TestRunRejectsNilHandler
--- PASS: TestRunRejectsNilHandler (0.00s)
=== RUN TestRunRecoversHandlerPanic
--- PASS: TestRunRecoversHandlerPanic (0.00s)
=== RUN TestRunRejectsInvalidPathWithoutPanic
--- PASS: TestRunRejectsInvalidPathWithoutPanic (0.00s)
PASS
ok webapp/internal/baseline 0.247s
0.247s 是一次本机运行的耗时,会随机器和负载变化。-run 'TestRun' 只匹配端到端运行器的测试,不会执行模块图和基线对比。
小结
端到端用例从用户旅程来,不从接口清单来。每一步既要断言状态码,也要断言业务结果,并且让下一步消费上一步的输出。注册、登录、改资料、踢旧设备连成一条链,才能覆盖账号包与 BFF 之间的字段名、会话和可见性接缝。
运行器要满足三个性质:状态码不符继续跑,panic 不穿出,非法输入变成失败记录而不是崩溃。Run 返回的 Failed 必须带步骤名和差异,测试报告才能直接定位问题。
下一节收口全卷:把这一卷交付的可运行产物、基线指标和进入卷二的理由说清楚。
阅读导航:上一节:13.1 账号、资料与 BFF 的模块划分 · 下一节:13.3 基线指标与进入卷二 。
常见错误与边界
- 按接口清单写用例。每个接口都返回 200,但没有任何一条用户旅程被完整走通。
- 只断言状态码。201 但没落库、200 但 token 为空,都能骗过测试。
- 用例之间共享账号或数据库快照。失败无法单独重跑,顺序一变结果就变。
- 用两套手写 fixture 验证同一个契约。两边都绿,接缝处字段名却不一致。
- 失败就停。一次运行只暴露第一个问题,修复和回归的轮次被拉长。
- 让 panic 穿出运行器。一个坏步骤让整份报告丢失,后面的场景也没跑。
- 把端到端测试当集成测试。它固定 HTTP 接缝,不覆盖数据库、网络和外部身份提供商。
- 只测服务端内部,不测客户端消费的字段。字段名修好了,客户端仍然读旧名字。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。