切换主题
测试与故障验证
问题:单元测试全绿,为什么上线后重复执行
mock Repository 始终返回成功,不会模拟两个事务同时认领;mock 消息发送成功,也不会模拟消息到达但 ACK 丢失。测试通过只证明所覆盖条件成立,不能自动推广到所有边界。
从业务不变量开始设计测试:“重复请求不创建第二个 Task”“旧 claim 无法确认新租约”“提交后崩溃不会丢待发事实”。这些比测某个内部方法被调用几次更稳定。
原理:让测试接近需要证明的机制
领域规则与调度纯函数用单元测试;数据库唯一约束、锁和原子写入要用真实数据库集成测试;接口结构用契约测试;跨进程通信、认证和恢复用端到端测试;耐久工作流升级还需历史 Replay 验证。
每个测试写清前置状态、触发、可观察证据和允许差异。最终一致性不要仅 sleep 固定秒数;在有截止时间的范围内轮询业务条件,同时在失败时保存诊断证据。
最小例子:测试故障窗口
text
Outbox 故障测试:
1. 提交业务与事件,确认两条持久记录都存在。
2. 发送成功后模拟记录确认失败。
3. 推进测试时钟超过租约,重新认领同一事件 ID。
4. 消费端处理两次投递。
5. 断言业务效果只有一次,事件最终确认,原始 ID 未变化。如果只断言“Publish 被调用两次”,没有验证消费端事务,则不能证明业务幂等。测试时钟适合纯逻辑,真实数据库的时间条件还需检查实际 SQL。
方案比较
| 测试 | 能证明 | 不能单独证明 |
|---|---|---|
| 单元 | 规则、排序、状态转换 | 真实事务与网络 |
| 集成 | SQL、驱动、锁、唯一约束 | 全链路配置 |
| 契约 | wire shape、校验与兼容 | 业务状态正确 |
| E2E / 故障 | 多组件推进和恢复 | 全部并发组合 |
| Replay | 历史工作流兼容 | 外部副作用幂等 |
mock 适合隔离协作者的错误路径,但过度模拟会把错误假设写进测试。并发测试要控制竞争窗口,避免偶尔跑到某种调度顺序才“碰巧通过”。
真实案例:生成门禁与跨组件验证
backend Makefile 提供单元、race、Schema、配置、架构和生成检查。根 Workbench E2E 通过独立 Compose 项目验证多组件交互。源契约变化后需要再生成并检查 drift,不能只验证 Go 编译。
已核对的实现 · 本地代码快照
生成与工程门禁 backend · c6dbe05c
Makefile · 第 37–83 行
符号:generate / check · 核对日期 2026-10-02
来源与提交版本一致
generate:
$(GO) tool oapi-codegen -config contracts/oapi-codegen/public.yaml contracts/openapi/public/v1/openapi.yaml
$(GO) tool oapi-codegen -config contracts/oapi-codegen/worker.yaml contracts/openapi/worker/v1/openapi.yaml
$(GO) tool oapi-codegen -config contracts/oapi-codegen/management.yaml contracts/openapi/management/v1/openapi.yaml
$(GO) tool oapi-codegen -config contracts/oapi-codegen/callback.yaml contracts/openapi/callback/v1/openapi.yaml
$(GO) tool oapi-codegen -config contracts/oapi-codegen/resource-provider.yaml contracts/openapi/resource-provider/v1/openapi.yaml
$(GO) tool sqlc generate
$(GO) run ./tools/sqlcpostprocess
migration-validate:
$(GO) tool goose -dir server/db/migrations validate
$(GO) tool goose -dir worker/db/migrations validate
generate-check: generate
git diff --exit-code -- contracts/gen/go server/internal/adapters/outbound/postgres/pgqueries worker/internal/adapters/outbound/sqlite/sqlitequeries
schema-check:
$(GO) test ./contracts/...
config-check:
TDP_SERVER_OIDC_CLIENT_SECRET=validation TDP_SERVER_DATABASE_URL=postgres://tdp:validation@localhost/tdp TDP_SERVER_NATS_URL=nats://localhost:4222 TDP_SERVER_DATA_ENCRYPTION_KEY=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc= $(GO) run ./server/cmd/server config validate --config configs/examples/server.yaml
$(GO) run ./worker/cmd/worker config validate --config configs/examples/worker.yaml
architecture-check:
$(GO) run ./tools/archcheck
api-compat:
@test -n "$(BASE_OPENAPI_DIR)" || (echo "BASE_OPENAPI_DIR is required"; exit 2)
$(GO) tool oasdiff breaking $(BASE_OPENAPI_DIR)/public/v1/openapi.yaml contracts/openapi/public/v1/openapi.yaml
$(GO) tool oasdiff breaking $(BASE_OPENAPI_DIR)/worker/v1/openapi.yaml contracts/openapi/worker/v1/openapi.yaml
vuln:
$(GO) tool govulncheck ./...
license-check:
$(GO) tool go-licenses check ./server/cmd/server ./worker/cmd/worker \
--ignore=tdp \
--ignore=github.com/nexus-rpc/nexus-proto-annotations/go/nexusannotations/v1
check: fmt-check vet test schema-check config-check architecture-check migration-validate
clean:
$(GO) clean ./...
rm -f $(BIN_DIR)/server $(BIN_DIR)/worker
rm -f $(BIN_DIR)/server-linux-amd64 $(BIN_DIR)/worker-linux-amd64
rm -f $(BIN_DIR)/worker-linux-arm64 $(BIN_DIR)/worker-windows-amd64.exe
片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
本地栈与验证入口 workbench · 514f5a00
Makefile · 第 19–55 行
符号:check / up / e2e · 核对日期 2026-10-02
来源与提交版本一致
check:
$(COMPOSE) -f compose.yaml config --quiet
$(COMPOSE) -f compose.yaml -f compose.dev.yaml config --quiet
$(COMPOSE) -f compose.yaml -f compose.dev.yaml -f compose.demo.yaml config --quiet
$(COMPOSE) -f compose.yaml -f compose.e2e.yaml config --quiet
$(COMPOSE) -f compose.yaml -f compose.demo.yaml config --quiet
python3 scripts/check-doc-links.py
docs-check:
python3 scripts/check-doc-links.py
ps:
$(LOCAL_COMPOSE) ps -a
up:
$(LOCAL_COMPOSE) up -d --build
down:
$(LOCAL_COMPOSE) down
logs:
$(LOCAL_COMPOSE) logs -f
e2e:
TDP_E2E_PROJECT=$(E2E_PROJECT) ./scripts/e2e.sh
e2e-down:
$(COMPOSE) -p $(E2E_PROJECT) -f compose.yaml -f compose.e2e.yaml $(E2E_EXTRA_COMPOSE) --profile test down
fault-test:
TDP_E2E_PROJECT=$(E2E_PROJECT) ./scripts/fault-test.sh
demo-up:
$(LOCAL_COMPOSE) -f compose.demo.yaml up -d --build
demo-down:
$(LOCAL_COMPOSE) -f compose.demo.yaml down片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
本学习站自己的测试验证教学模型的故障结果和移动交互,不等同于再次验收业务系统。没有改变业务代码时,不应启动完整业务 E2E 来替代网站测试。
失败与边界:环境失败不是逻辑证据
无法绑定端口、下载依赖失败或沙箱禁止缓存写入,要与断言失败区分。将 unchanged tests 在合适环境重跑后才能下结论。也不能因环境限制就报告业务已通过。
测试清理保留数据库与用户数据。集成测试使用隔离资源;故障注入必须作用于测试栈,不把学习按钮连到真实系统。
迁移练习与参考答案
练习:新增消息认领 SQL,单元测试已覆盖失败和重试,还需要什么?
参考答案:真实数据库测试验证并发认领不重叠、过期记录可回收、旧 claim 更新失败、事务中断不丢事实;集成消息测试验证重复投递与 ACK 窗口;涉及跨组件路径时做 E2E。保留最终数据库证据,不能只看日志说“看起来正常”。