离线测试

matex 的测试基座只有一个目标:不装 Docker、不连网络,也能跑通完整的路径。 这不是为了"跑得快",而是因为 CI 里没有中间件——离线跑不起来的测试等于不存在的测试。

四个基座

基座 默认实现 切到真服务 入口
数据库 每个测试一个纯 Go SQLite 临时文件,自动跑迁移 DB_TEST_DRIVER + DB_TEST_DSN dbtest.Start(t)
Redis 进程内 miniredis REDIS_TEST_ADDR redistest.Start(t) / redistest.Addr(t)
Kafka 进程内 broker(kfake) — kfake.NewCluster(...) + kafka.Config{Brokers: cluster.ListenAddrs()}
gRPC / rpcx 进程内 server,监听 127.0.0.1:0,用真 client 拨号 — grpcx.New / rpcx.New + Serve()

数据库:dbtest.Start

// dbtest.Start 返回一个已迁移的 *db.DB,清理挂到 t 上。
func Start(t testing.TB) *db.DB
func TestDAOByID(t *testing.T) {
	d := dbtest.Start(t)
	dao := NewDAO(d)

	if _, err := dao.ByID(t.Context(), 1); !errors.Is(err, db.ErrNoRow) {
		t.Fatalf("want ErrNoRow, got %v", err)
	}
}

三个细节值得知道:

make test-short                      # 默认:纯 Go SQLite,离线可跑
DB_TEST_DRIVER=postgres DB_TEST_DSN='postgres://...' go test ./...
DB_TEST_DRIVER=mysql    DB_TEST_DSN='user:pass@tcp(localhost:3306)/matex?parseTime=true' go test ./...

Redis:redistest

func Start(t testing.TB) *redis.Client   // miniredis,或 REDIS_TEST_ADDR 指向的真服务
func Addr(t testing.TB) string           // 只想要地址时用(自己建 client 的代码)
// examples/cache 的测试:Setup 走真路径,只把 redis 换成 miniredis。
func newTestService(t *testing.T, cfg *cacheConfig) (*cacheService, http.Handler) {
	svc := &cacheService{}
	if err := svc.Setup(t.Context(), &verticle.Env{Metrics: obs.NewMetrics()}); err != nil {
		t.Fatalf("setup: %v", err)
	}
	svc.redis = redistest.Start(t)
	svc.users = newUserStore()          // 回源换成一个刻意慢 50ms 的假查询
	// …BuildRouter 后返回 srv.Handler()
}

要断言"并发 miss 只回源一次",在假源里放一个计数器即可:8 个并发请求之后 source_reads 仍必须是 1。

HTTP:httptest 打 srv.Handler()

不需要绑定端口。httpx.Server.Handler() 返回的就是带完整封套(trace id、 超时、错误映射)的 http.Handler,用 httptest 直接驱动:

func newTestHandler(t *testing.T) http.Handler {
	t.Helper()
	srv := httpx.New(httpx.Config{Timeout: time.Second, MaxBody: 1 << 20})
	svc := &httpService{cfg: httpConfig{Greeting: "hi ", APIKey: "demo-key"}}
	if err := svc.BuildRouter(srv); err != nil {
		t.Fatalf("build router: %v", err)
	}
	return srv.Handler()
}

req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"name":"alice"}`))
req.Header.Set("X-API-Key", "demo-key")
rec := httptest.NewRecorder()
newTestHandler(t).ServeHTTP(rec, req)
// 断言 rec.Code、rec.Body、rec.Header().Get("X-Request-ID")…

这条路会经过 guard 与中间件,所以测试断言的是真实行为,而不是绕过封套的伪路径。 examples/http 的用例表就是按状态码与业务 code 断言的。

Kafka:kfake

cluster, err := kfake.NewCluster(
	kfake.NumBrokers(1),
	kfake.SeedTopics(1, topic),
)
if err != nil {
	t.Fatalf("kfake: %v", err)
}
t.Cleanup(cluster.Close)

cfg := kafka.Config{Brokers: cluster.ListenAddrs(), ClientID: "kafkademo-test"}
p, _ := kafka.NewProducer(cfg)
go kafka.RunConsumer(ctx, cfg, group, []string{topic}, handler)   // 真循环、真提交
err = p.SendSync(ctx, topic, []byte("k"), []byte("v"))

kfake 是 franz-go 自带的进程内 broker。测试里的 producer / consumer 就是生产代码用的那一套, 所以 at-least-once 的提交语义在测试里也是真的。

唯一一处依赖豁免 depguard 的 examples-no-drivers 规则禁止 examples 直接 import 驱动库, 唯一例外就是 kafka 示例测试里的 franz-go/pkg/kfake。 非测试代码依然不豁免。

gRPC / rpcx:进程内起 server

// gRPC:不需要 .proto——用内置健康服务就能验证 server + client 的完整往返。
srv, _ := grpcx.New(grpcx.Config{Addr: "127.0.0.1:0"})
go func() { _ = srv.Serve() }()
t.Cleanup(func() { _ = srv.Shutdown(ctx) })

conn, err := grpcx.Dial(ctx, srv.Addr())
resp, _ := healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
// rpcx:真 server + 真 client,走完整的注册/调用路径。
srv := rpcx.New(rpcx.Config{Network: "tcp", Addr: "127.0.0.1:0"})
srv.RPCX().RegisterName("Greeter", &Greeter{prefix: "Hi "}, "")
go func() { _ = srv.Serve() }()

cl, _ := rpcx.PeerClient("Greeter", addr)      // rpcx 在 Serve 里才 bind,需等地址出现
cl.Call(ctx, "Greet", &GreetArgs{Name: "world"}, &reply)

监听 :0 由内核分配端口,所以同一测试套件可以并发跑。 gRPC 的 server 在 New 时就已绑定地址,Addr() 立刻可用; rpcx 在 Serve 里才 bind,所以测试要轮询 srv.RPCX().Address() 直到非 nil。

CI 门禁

因为四个基座都在进程内,make ci 就能在没有 Docker 的环境里真正验证每一个 示例——它们都带测试,而且都能离线跑:

flowchart TD
    A["make ci"] --> V["go vet ./..."]
    V --> T["go test -short -race -count=1 ./..."]
    T --> D["dbtest
纯 Go SQLite + 迁移"] T --> R["redistest
miniredis"] T --> K["kfake
进程内 broker"] T --> G["grpcx / rpcx
127.0.0.1:0 进程内"] T --> H["httptest
srv.Handler()"] D --> P{全部通过} R --> P K --> P G --> P H --> P P -->|是| B["go build ./..."] P -->|否| F(["CI 失败"]) B --> OK(["可交付"]) classDef gate fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class A,P gate
GitHub Actions 跑同一套命令并加 golangci-lint;本机 make ci 与 CI 一致, 不存在"本地过了 CI 挂"的差别。
每个示例都必须带测试 这不是风格建议:示例索引把每个能力都列成一个可运行页面,而"可运行"由测试保证。 加新示例时同时加 main_test.go,否则它只是一段会腐烂的演示代码。

相关示例