从 Node.js 到 Go Web(6):用测试区分业务与传输边界
- 应用入口与 HTTP 处理
- 从 Proto 找到路由与参数
- 请求消息与响应转换
- 对象装配、Context 与登录用户
- 沿着创建 Memo 读完整请求
- 用测试区分业务与传输边界(当前)
调用 CreateMemo 成功,能证明业务处理了一条消息;向 REST 入口发送 JSON 成功,还能证明路由和解码正确。这两类测试都需要,但它们覆盖的范围不同。Memos b40d8a7b5737 同时提供了直接服务测试、传输适配测试和 HTTP Handler 测试,可以借它建立验证层次。
直接调用服务,验证业务规则
NewTestService()创建测试 Store 和 APIService。测试可以用 CreateUserContext() 放入 UserID,然后直接调用服务:
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
memo, err := ts.Service.CreateMemo(ownerCtx, &apipb.CreateMemoRequest{
Memo: &apipb.Memo{Content: "public memo", Visibility: apipb.Visibility_PUBLIC},
})
这是 TestAnonymousMemoAccessFollowsInstanceAccessSetting 中创建步骤的排版节选。测试随后改变实例公开访问设置,确认匿名 GetMemo 和 ListMemos 的结果随之变化。这里确实触及业务和测试数据库,但没有发送 HTTP 请求,也没有验证 Bearer Token、JSON 字段名或 Gateway 的路由绑定。
CreateUserContext() 的实现只是写 Context 值。它是测试安排好的身份条件,类似在 Node.js 单元测试中给服务传入 currentUser。不能因为这种测试通过,就宣称登录鉴权链路也验证通过。
Handler 测试覆盖路由和协议表达
net/http/httptest 提供 Request、ResponseRecorder 和测试服务器等工具。Memos 的 旧版本路由测试创建实际 API Handler,再通过 ServeHTTP 调用它:
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
它断言旧 /api/v1/memos 返回 410,并检查错误体中的 code 和迁移说明。这里没有真实网络监听,但经过了应用 Handler。它和 Express 里用 Supertest 直接测 app 的思路接近:覆盖请求处理链,不必为了每个断言启动独立进程。
TestConvertGRPCErrorUsesStatusMessage则更窄:只检查错误转换是否保留正确 code/message,且没有把 rpc error: 这类传输内部描述拼进公开 message。测试应围绕实际职责选范围,没必要让每一个转换断言都走完整服务器。
一个修改应该补在哪层
| 修改 | 主要验证位置 | 仅测这一层还不够说明什么 |
|---|---|---|
| Memo 可见性或归属规则 | 直接服务测试,带不同用户 Context | HTTP 凭据识别、字段映射 |
| REST body、查询或路径绑定 | Handler / Gateway 请求测试 | 所有业务权限组合 |
| Connect 错误包装 | 适配器单元测试,必要时加过程调用 | REST 错误体形状 |
| 取消或超时传递 | 支持取消的下游配合 Context 测试 | 生产负载下的实际耗时 |
比如调整 Proto 的 body annotation,只调用 CreateMemo 很可能仍然全绿,因为服务函数不读取 annotation。应发送真实形状的 HTTP 请求,断言服务收到的消息;调整匿名访问策略,则需要同时检查两种传输入口是否遵守同一规则。
如何运行,以及本次核对范围
在匹配 go.mod 的工具链和依赖可用后,可以先运行有针对性的命令,再扩大范围:
go test ./server/api -run 'TestRemovedAPIVersionRoutesReturnGone|TestConvertGRPCErrorUsesStatusMessage' -count=1
go test ./server/api/test -run '^TestAnonymousMemoAccessFollowsInstanceAccessSetting$' -count=1
测试用的 Store、临时目录和清理由项目测试辅助函数管理;整套服务测试还涉及更多文件和依赖,不应把上述命令当作所有包的完整验收。完整应用运行也需要遵守仓库对前端嵌入产物和启动参数的要求。
2026-11-03 使用 Go 1.27.2 和 SQLite 执行上述选择性测试,三个顶层测试全部通过;旧路由测试中的三个子场景也通过。该结果支持已选路由、错误转换和匿名访问规则,不代表整个仓库测试套件或完整登录流程已经通过。 选择的测试、环境与结果节选见测试结果弹窗。
总结
测试的价值在于确认改动所处的边界。直接服务调用验证消息进入业务后的规则,Handler 测试验证 HTTP 入口和序列化,适配器测试验证协议转换。读到测试时,先看它从哪里进入、替换了什么、依赖哪些真实资源,再判断一条通过结果究竟能支持多大的结论。
选择性测试的环境与结果
记录保留固定源码提交、工具链、运行命令和测试结果。Go 业务测试使用临时 SQLite 数据库;通过自建 Context 指定用户的测试未覆盖 HTTP 凭据验证。
结果原始字段
{
"date": "2026-11-03",
"repository": "usememos/memos",
"commit": "b40d8a7b573774c52b022c8d6537d6baf2129dc0",
"toolchain": "go1.27.2 windows/amd64",
"driver": "sqlite",
"sampling": "Selected test names and pass records; database initialization logs omitted.",
"runs": [
{
"command": "go test ./server/api -run 'TestRemovedAPIVersionRoutesReturnGone|TestConvertGRPCErrorUsesStatusMessage' -count=1",
"exitCode": 0,
"output": "ok github.com/usememos/memos/server/api 0.441s",
"tests": [
"TestRemovedAPIVersionRoutesReturnGone",
"TestConvertGRPCErrorUsesStatusMessage"
]
},
{
"command": "DRIVER=sqlite go test ./server/api/test -run '^TestAnonymousMemoAccessFollowsInstanceAccessSetting$' -count=1 -json",
"exitCode": 0,
"tests": [
{
"Package": "github.com/usememos/memos/server/api/test",
"Test": "TestAnonymousMemoAccessFollowsInstanceAccessSetting",
"Action": "pass",
"Elapsed": 0.08
}
]
}
],
"limits": [
"Selected tests only; not the complete repository suite.",
"Direct service tests inject an identity Context and do not verify HTTP credentials."
]
}