跳到正文

从 Node.js 到 Go Web(6):用测试区分业务与传输边界

从 Node.js 到 Go Web

  1. 应用入口与 HTTP 处理
  2. 从 Proto 找到路由与参数
  3. 请求消息与响应转换
  4. 对象装配、Context 与登录用户
  5. 沿着创建 Memo 读完整请求
  6. 用测试区分业务与传输边界(当前)

调用 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 的思路接近:覆盖请求处理链,不必为了每个断言启动独立进程。

直接服务测试和 Handler 测试经过不同的调用边界

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 入口和序列化,适配器测试验证协议转换。读到测试时,先看它从哪里进入、替换了什么、依赖哪些真实资源,再判断一条通过结果究竟能支持多大的结论。

选择性测试的环境与结果

打开原文件 test-results.json

记录保留固定源码提交、工具链、运行命令和测试结果。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."
  ]
}