离线测试
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)
}
}
三个细节值得知道:
-
每个测试一个临时文件(
file:<t.TempDir()>/matex.db), 不用:memory:——文件库允许连接池安全地开多个连接,测试之间也天然隔离。 -
自动跑迁移:从本文件位置向上三级定位仓库的
migrations/目录, 按文件名序应用。迁移写错在这里就会暴露。 -
切真库是环境变量的事,同一套测试不用改一行代码:
DB_TEST_DRIVER为空时默认postgres。
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
make ci 与 CI 一致,
不存在"本地过了 CI 挂"的差别。
每个示例都必须带测试
这不是风格建议:示例索引把每个能力都列成一个可运行页面,而"可运行"由测试保证。
加新示例时同时加
main_test.go,否则它只是一段会腐烂的演示代码。
相关示例
- examples/database:
dbtest.Start+ 断言 DAO 行为。 - examples/cache:
redistest.Start+ 并发 miss 合并的断言。 - examples/kafka:
kfake进程内 broker。 - examples/grpc、examples/rpcx:进程内 server + 真 client。
- examples/http:
httptest打srv.Handler()。