手机上未读清零了,平板上还挂着 3 条
「回声」的私信功能上线前,服务端单测全部通过:未读数加一、减一、归零,每个函数都有用例;接口测试也通过:登录返回 200,发消息返回 201,已读返回 204。团队认为可以发版了。
联调时产品同学拿了两台设备。手机登录 A 账号,平板登录 B 账号。B 在平板上发三条消息,A 的手机收到推送,会话列表显示未读 3。A 在手机上点开会话,未读变成 0;再打开平板上的同一个账号,未读数还是 3。刷新、杀进程、重新登录,平板仍然是 3。
排查发现,两端上报的会话 ID 不是同一个字符串。手机端把服务端返回的 conv_1001 原样带回;平板端在本地做了一次“规范化”,把下划线去掉,变成了 conv1001。服务端的未读表以会话 ID 为键,于是同一个会话被记成了两条记录:一条属于手机,一条属于平板。手机已读只清了手机那条,平板那条还在。
这不是某个函数的 bug。单测里的会话 ID 是写死的,接口测试只发了单个设备;两条路径各自都正确,拼在一起才暴露问题。端到端测试的价值,正是在“拼起来”的地方抓这种不一致。
四层测试,各抓各的问题
多端 IM 的问题不会均匀分布在每一层。单测抓算法,接口测试抓契约,端到端抓身份和状态在设备之间的传递,线上灰度抓真实网络和客户端版本。用一层替代另一层,都会留下盲区。
| 测试层 | 典型覆盖 | 能抓到什么 | 抓不到什么 |
|---|---|---|---|
| 单元测试 | 未读加一、减一、归零 | 计数逻辑、边界条件 | 会话 ID 从哪来、设备之间是否一致 |
| 接口测试 | 单设备登录、发消息、已读 | 路由、状态码、请求体校验 | 多端共享同一账号时的状态合并 |
| 端到端测试 | 两台设备串起完整用户动作 | 身份、会话 ID、已读回执的端到端一致性 | 真实弱网、推送通道、客户端缓存 |
| 线上灰度 | 真实客户端和网络 | 版本差异、网络分区、推送延迟 | 低频边界,除非有足够流量 |
单测和接口测试都默认“会话 ID 是给定的正确值”。端到端测试必须从用户动作出发:登录、发消息、另一端收到、点开、未读归零。每一步都使用上一步真实返回的 ID,而不是测试里写死的常量。
这也是为什么端到端用例不适合用 mock 替代 HTTP。mock 只能验证“我期望服务端被怎么调用”,不能验证“服务端返回的 ID 被客户端原样带回来”。用 net/http/httptest 启动一个真实监听的服务,走 loopback 网络,虽然不经过公网,但请求构造、路由匹配、状态码和响应体都经过真实的 net/http 栈。
用例要覆盖“另一个设备”,而不只是“另一个请求”
同一个账号在两台设备上,至少有三种状态需要验证:消息发送后,接收端未读加一;任一设备已读后,所有设备的未读归零;设备重新登录后,从服务端拉到的未读与本地一致。只发两个连续请求,不能证明这些状态是共享的。
案例假设值:一个用户平均 1.8 台设备,未读状态每天被读写 40 次;这些数字用于说明多端不是边缘场景,不是实测结果。
失败场景:单测全绿,会话 ID 却在客户端被改写
把事故链路展开,能看到三个断裂点。
第一个断裂点在客户端。平板端为了“统一格式”,对服务端返回的会话 ID 做了本地规范化。这个改动在单端测试里完全无害,因为请求和响应都用同一个规范化函数;但服务端的未读表并不认识这个格式。
第二个断裂点在接口契约。服务端返回的会话 ID 是带下划线的字符串,但没有在契约里声明“客户端必须原样回传”。接口测试只校验了状态码,没有校验响应体里的 ID 被后续请求使用。
第三个断裂点在测试设计。端到端用例只用了手机一台设备,已读请求直接写死 conv_1001,恰好和服务端的键一致,所以测试通过。平板那条 conv1001 的路径从未被走到。
修复分三步。第一,会话 ID 改为不透明字符串,客户端不得解析、拼接或规范化;第二,接口测试增加“响应 ID 回传”的断言;第三,端到端用例至少覆盖两台设备,并且所有路径参数都来自上一步的响应,而不是常量。
第三步最难自动化,因为需要在步骤之间传递变量。一个折中的做法是:端到端框架先支持固定路径,把状态码和请求顺序跑通;再在需要动态 ID 的场景里,用测试专用的 handler 或中间层把上一步的响应写进下一步的路径。无论哪种做法,都不能退回“每步写死一个 ID”的老路。
用 httptest 真跑一条多端链路
下面的代码来自 webapp-lab。Run 接收一个 Scenario 和一个 http.Handler,用 httptest.NewServer 启动真实服务,再按顺序发送请求。请求步骤命名为 RequestStep,避免和 13.1 里表示链路阶段的 Step 重名。
// file: internal/socialbase/e2e.go
// tier: L1
// verify: go test ./internal/socialbase/
package socialbase
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
// RequestStep 是一个端到端请求步骤。它在包内与 13.1 的链路 Step 区分开。
type RequestStep struct {
Name string
Method string
Path string
Body string
WantStatus int
}
// Scenario 是按顺序执行的一组请求。
type Scenario struct {
Name string
Steps []RequestStep
}
// Result 记录通过数与失败步骤的描述。
type Result struct {
Passed int
Failed []string
}
// Run 启动真实的 httptest 服务并逐步发送请求。状态码不符会记入 Failed 并继续。
func Run(sc Scenario, h http.Handler) (Result, error) {
if len(sc.Steps) == 0 {
return Result{}, errors.New("scenario has no steps")
}
if h == nil {
return Result{}, errors.New("handler is nil")
}
srv := httptest.NewServer(guarded(h))
defer srv.Close()
client := srv.Client()
var res Result
for _, st := range sc.Steps {
req, err := http.NewRequest(st.Method, srv.URL+st.Path, strings.NewReader(st.Body))
if err != nil {
res.Failed = append(res.Failed, fmt.Sprintf("%s: build request: %v", st.Name, err))
continue
}
resp, err := client.Do(req)
if err != nil {
res.Failed = append(res.Failed, fmt.Sprintf("%s: request failed: %v", st.Name, err))
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != st.WantStatus {
res.Failed = append(res.Failed, fmt.Sprintf("%s: status %d, want %d", st.Name, resp.StatusCode, st.WantStatus))
continue
}
res.Passed++
}
return res, nil
}
func guarded(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recover() != nil {
http.Error(w, "handler panic", http.StatusInternalServerError)
}
}()
h.ServeHTTP(w, r)
})
}
Run 先拒绝空场景和空 handler,避免调用方拿到一个“零步骤全通过”的假结果。然后启动服务,复用 srv.Client(),逐步构造请求。请求构造失败、网络失败和状态码不符都记入 Failed,但不会中断后续步骤;这样一次运行能看到所有失败,而不是只看到第一个。
guarded 包了一层 panic 恢复。handler 内部 panic 时,客户端会收到 500,而不是连接被直接掐断;Run 本身不会 panic,失败会以状态码不符的形式进入结果。这个设计不是为了掩盖 panic,而是让端到端测试在遇到一个坏步骤时还能继续跑完,并留下可读的失败记录。
Result 只记录通过数和失败描述,不返回响应体。端到端测试的第一层目标是“动作能否完成”,响应体的字段级断言应该放在更细的测试里;把两者混在一起,失败信息会变得又长又难定位。
测试:通过、失败继续、空场景和 panic
测试覆盖四类行为:完整多端链路全部通过;一个步骤状态码不符时记录失败并继续;空场景返回错误;handler panic 不会让 Run 崩溃。
// file: internal/socialbase/e2e_test.go
// tier: L1
// verify: go test ./internal/socialbase/
package socialbase
import (
"net/http"
"net/http/httptest"
"testing"
)
func multiDeviceApp() http.Handler {
state := &struct{ unread int }{}
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/conversations/c1/messages", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
state.unread++
w.WriteHeader(http.StatusCreated)
case http.MethodGet:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[{"id":"m1"}]`))
default:
http.Error(w, "method", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/conversations/c1/read", func(w http.ResponseWriter, r *http.Request) {
state.unread = 0
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/conversations/c1/unread", func(w http.ResponseWriter, r *http.Request) {
if state.unread != 0 {
http.Error(w, "unread not cleared", http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"count":0}`))
})
return mux
}
func TestRunMultiDeviceScenario(t *testing.T) {
sc := Scenario{Name: "two devices", Steps: []RequestStep{
{Name: "login phone", Method: http.MethodPost, Path: "/login", Body: `{"device":"phone"}`, WantStatus: http.StatusOK},
{Name: "login tablet", Method: http.MethodPost, Path: "/login", Body: `{"device":"tablet"}`, WantStatus: http.StatusOK},
{Name: "phone sends", Method: http.MethodPost, Path: "/conversations/c1/messages", Body: `{"text":"hi"}`, WantStatus: http.StatusCreated},
{Name: "tablet receives", Method: http.MethodGet, Path: "/conversations/c1/messages", WantStatus: http.StatusOK},
{Name: "tablet reads", Method: http.MethodPost, Path: "/conversations/c1/read", Body: `{"message":"m1"}`, WantStatus: http.StatusNoContent},
{Name: "phone unread", Method: http.MethodGet, Path: "/conversations/c1/unread", WantStatus: http.StatusOK},
}}
res, err := Run(sc, multiDeviceApp())
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != len(sc.Steps) || len(res.Failed) != 0 {
t.Fatalf("Run() = %+v, want all %d steps passed", res, len(sc.Steps))
}
}
func TestRunRecordsMismatchAndContinues(t *testing.T) {
calls := 0
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
sc := Scenario{Steps: []RequestStep{
{Name: "wrong", Method: http.MethodGet, Path: "/first", WantStatus: http.StatusOK},
{Name: "right", Method: http.MethodGet, Path: "/second", WantStatus: http.StatusOK},
}}
res, err := Run(sc, h)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Passed != 1 || len(res.Failed) != 1 {
t.Fatalf("Run() = %+v, want 1 passed and 1 failed", res)
}
if calls != 2 {
t.Fatalf("handler calls = %d, want 2 (Run must continue)", calls)
}
}
func TestRunRejectsEmptyScenario(t *testing.T) {
if _, err := Run(Scenario{}, multiDeviceApp()); err == nil {
t.Fatal("Run() error = nil, want error for empty scenario")
}
}
func TestRunSurvivesHandlerPanic(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("boom")
})
sc := Scenario{Steps: []RequestStep{
{Name: "panic", Method: http.MethodGet, Path: "/", WantStatus: http.StatusOK},
}}
res, err := Run(sc, h)
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if res.Passed != 0 || len(res.Failed) != 1 {
t.Fatalf("Run() = %+v, want one recorded failure", res)
}
}
func TestGuardedPreservesNormalStatus(t *testing.T) {
srv := httptest.NewServer(guarded(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})))
defer srv.Close()
resp, err := srv.Client().Get(srv.URL)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTeapot {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusTeapot)
}
}
multiDeviceApp 用一个内存状态模拟未读:发消息加一,已读清零,查询时如果未读不为零就返回 409。这样“未读数归零”不是靠响应体里写死一个 0,而是由服务端状态决定;如果已读步骤没有生效,最后一步会失败。
TestRunMultiDeviceScenario 串起六步:手机登录、平板登录、手机发消息、平板收消息、平板已读、手机查未读。TestRunRecordsMismatchAndContinues 用一个第一次返回 500、第二次返回 200 的 handler,确认 Passed 为 1、Failed 为 1,且 handler 被调用了两次。
TestRunRejectsEmptyScenario 和 TestRunSurvivesHandlerPanic 覆盖两个容易被忽略的边界:没有步骤时不能假装成功;handler panic 时不能把测试进程带崩。TestGuardedPreservesNormalStatus 则确认 guarded 不会改变正常响应的状态码。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/socialbase/ -run 'TestRun|TestGuarded' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRunMultiDeviceScenario
--- PASS: TestRunMultiDeviceScenario (0.01s)
=== RUN TestRunRecordsMismatchAndContinues
--- PASS: TestRunRecordsMismatchAndContinues (0.00s)
=== RUN TestRunRejectsEmptyScenario
--- PASS: TestRunRejectsEmptyScenario (0.00s)
=== RUN TestRunSurvivesHandlerPanic
--- PASS: TestRunSurvivesHandlerPanic (0.00s)
=== RUN TestGuardedPreservesNormalStatus
--- PASS: TestGuardedPreservesNormalStatus (0.00s)
PASS
ok webapp/internal/socialbase 0.308s
五个用例全部通过。TestRunMultiDeviceScenario 显示 0.01s,因为它真的启动了一个监听 loopback 的 HTTP 服务;其余用例大多在 0.00s 内完成。包级耗时包含测试二进制启动,不能当成单条链路的延迟。
小结
多端 IM 的端到端验证,重点不是“把接口再调一遍”,而是从用户动作出发,把登录、发消息、另一端接收、已读和未读归零串成一条真实链路。单测和接口测试默认 ID 是正确的,端到端测试必须证明 ID 在设备之间没有被改写。
Run 用 httptest 启动真实服务,状态码不符时记录并继续,panic 时降级为 500,空场景直接报错。这些约束让失败可见、可复现,而不是变成联调时的一句“你那边再刷新试试”。
下一节收口卷二:把 Feed 新鲜度、消息到达率和未读准确率变成可比较的指标,回答“这次改版到底变好还是变坏”。
阅读导航:上一节:13.1 发布→Feed→互动→通知 · 下一节:13.3 质量指标与进入卷三 。
常见错误与边界
- 端到端用例里写死会话 ID。测试通过只能证明这个常量和服务端一致,不能证明客户端回传的值一致。
- 只测单设备。多端共享账号时,未读、已读回执和会话列表都可能出现设备间的状态分裂。
- 用 mock 替代 HTTP 还号称端到端。mock 验证的是调用形状,不是真实路由、状态码和响应传递。
- 状态码不符就立刻停止。一次运行只暴露第一个失败,后面隐藏的问题要反复跑才能发现。
- 把 panic 当成“测试框架会处理”。不恢复 panic 时,一条坏步骤会让整轮用例失去后续结果。
- 空场景返回成功。零步骤的
Passed=0, Failed=0看起来像通过,实际什么都没验证。 - 只看状态码,不看状态变化。已读返回 204 不代表未读真的归零,服务端状态要在后续步骤里被查询。
- 客户端“规范化”服务端 ID。不透明字符串一旦被解析或改写,服务端的键就对不上,且单端测试很难发现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。