Go 的测试生态和其他语言不太一样:标准库 testing 只提供了最小的一套东西——跑用例、报失败、管清理,没有断言函数,没有 mock 框架,没有 setup/teardown 关键字。这不是缺失,是刻意的设计,所以社区库补的都是标准库故意不做的那部分。搞清楚每个库补的是哪一块,选型就不用靠感觉。
下面所有代码都在 go1.26.0 darwin/arm64 上实际跑过,版本是 testify v1.12.1、go.uber.org/mock v0.6.0、go-sqlmock v1.5.2、testcontainers-go v0.44.0。
标准库 testing 先能做到哪一步 上任何第三方库之前,先看清楚标准库自带了什么。很多人以为必须装 testify 才能写测试,其实标准库覆盖的范围比想象中大。
被测代码用下面这个例子,后面几节都拿它举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 type User struct { ID int Name string Email string } var ErrNotFound = errors.New("user not found" )type UserRepo interface { FindByID(ctx context.Context, id int ) (*User, error ) } type UserService struct { repo UserRepo } func NewUserService (r UserRepo) *UserService { return &UserService{repo: r} }func (s *UserService) DisplayName(ctx context.Context, id int ) (string , error ) { u, err := s.repo.FindByID(ctx, id) if err != nil { return "" , err } if u.Name == "" { return u.Email, nil } return u.Name, nil }
表驱动测试与子测试 Go 社区最主流的写法是把用例列成一张表,再用 t.Run 逐个跑。拿上面那条”没填名字就用邮箱”的规则单独拆出来测:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 func pickDisplayName (u User) string { if u.Name == "" { return u.Email } return u.Name } func TestPickDisplayName (t *testing.T) { cases := []struct { name string user User want string }{ {"有名字就用名字" , User{Name: "Alice" , Email: "a@example.com" }, "Alice" }, {"没名字退回邮箱" , User{Name: "" , Email: "b@example.com" }, "b@example.com" }, } for _, c := range cases { t.Run(c.name, func (t *testing.T) { got := pickDisplayName(c.user) if got != c.want { t.Errorf("got %q, want %q" , got, c.want) } }) } }
t.Errorf 记一个失败然后继续往下跑,t.Fatalf 记完立刻中断当前用例。取值失败之后再往下走只会连环报错的地方,用 Fatalf;只是想一次看到多个断言的结果,用 Errorf。
三个容易被忽略的辅助方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func setupTempFile (t *testing.T) string { t.Helper() f, err := os.CreateTemp("" , "demo-*.txt" ) if err != nil { t.Fatal(err) } t.Cleanup(func () { os.Remove(f.Name()) }) return f.Name() }
Go 1.24 起 *testing.T 还带了 t.Context(),返回一个跟着用例生命周期走的 context——用例结束时它会自动取消,不用再自己写 context.WithCancel 加 defer cancel()。
测超时和定时逻辑:testing/synctest 涉及超时、重试、定时器的代码最难测,因为测试得真的等那么久。Go 1.25 起标准库里有了 testing/synctest,它给测试里的 goroutine 造一个假的时钟:代码里 time.After(5 * time.Second) 会立刻推进,不用真等 5 秒。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func waitWithTimeout (ch <-chan int , d time.Duration) (int , bool ) { select { case v := <-ch: return v, true case <-time.After(d): return 0 , false } } func TestTimeout (t *testing.T) { start := time.Now() synctest.Test(t, func (t *testing.T) { ch := make (chan int ) _, ok := waitWithTimeout(ch, 5 *time.Second) if ok { t.Fatal("应该超时" ) } }) t.Logf("真实墙钟耗时: %v" , time.Since(start)) }
实际跑出来的结果:
1 2 3 === RUN TestTimeoutWithSynctest synctest_test.go:28: 真实墙钟耗时: 50.708µs --- PASS: TestTimeoutWithSynctest (0.00s)
一个语义上等了 5 秒的用例,真实耗时 50 微秒。
testify:断言与 suite 标准库没有断言函数,所有判断都得自己写 if got != want。字段一多,测试代码里全是这种模板。testify 补的就是这一块。
assert 和 require 的区别是”失败之后还跑不跑” 这两个包的函数签名完全一样,区别只有一个:失败之后的行为。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAssert (t *testing.T) { ok := assert.Equal(t, 1 , 2 ) t.Logf("assert.Equal 返回值 = %v,函数继续执行到这里" , ok) } func TestRequire (t *testing.T) { u, err := repo.FindByID(ctx, 1 ) require.NoError(t, err) require.Equal(t, "Alice" , u.Name) }
实测 assert.Equal(t, 1, 2) 的输出,可以看到断言失败之后 t.Logf 那一行照样打印了出来:
1 2 3 === RUN TestAssertFailKeepsRunning testify_test.go:27: assert.Equal 返回值 = false,函数继续执行到这里 --- FAIL: TestAssertFailKeepsRunning (0.00s)
用哪个的判断标准很直接:后面的代码依赖这个断言的结果,就用 require;只是想一次性看到多个字段哪些对不上,就用 assert 。取指针、取 error、拿返回值这类前置检查一律 require,检查结构体各个字段的值用 assert。
常用的几个断言:
1 2 3 4 5 require.NoError(t, err) require.ErrorIs(t, err, ErrNotFound) require.JSONEq(t, `{"status":"ok"}` , body) require.InDelta(t, 26.5 , temp, 0.001 ) require.Len(t, users, 3 )
ErrorIs 和 JSONEq 这两个尤其省事:前者不用自己套 errors.Is,后者不用担心两段 JSON 字段顺序不同却是等价的。关于 errors.Is 和 errors.As 的语义差别,之前单独写过一篇 Go 错误处理进阶 。
suite:需要共享 setup 时才用 Go 没有 JUnit 那种 @Before 注解,多个用例要共享同一套初始化,标准做法是写个 setup(t) 辅助函数手动调。testify 的 suite 包提供了另一种选择:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 type UserServiceSuite struct { suite.Suite repo *MockUserRepo svc *UserService } func (s *UserServiceSuite) SetupTest() { s.repo = NewMockUserRepo(gomock.NewController(s.T())) s.svc = NewUserService(s.repo) } func (s *UserServiceSuite) TestName() { s.repo.EXPECT().FindByID(gomock.Any(), 1 ).Return(&User{ID: 1 , Name: "Alice" }, nil ) got, err := s.svc.DisplayName(context.Background(), 1 ) s.Require().NoError(err) s.Equal("Alice" , got) } func TestUserServiceSuite (t *testing.T) { suite.Run(t, new (UserServiceSuite)) }
跑出来是这样,套件下的每个用例都是一个子测试:
1 2 3 4 5 6 === RUN TestUserServiceSuite === RUN TestUserServiceSuite/TestEmailFallback suite_test.go:22: SetupTest 每个用例前都会跑一次 === RUN TestUserServiceSuite/TestName suite_test.go:22: SetupTest 每个用例前都会跑一次 --- PASS: TestUserServiceSuite (0.00s)
suite 不是必需品。初始化只有一两行的时候,普通的 setup(t) 函数更直白,也不用记 SetupTest 这些约定的方法名。只有当一组用例共享的初始化又长又多、而且要保证每个用例拿到的是全新实例时,suite 才划算。
go.uber.org/mock:给接口打桩 UserService 依赖 UserRepo 这个接口。测 DisplayName 的逻辑不该连数据库,得给这个接口塞一个假的实现进去。手写当然可以,但要控制”第几次调用返回什么””必须被调用一次”这类行为,手写就啰嗦了。
为什么是 uber 这个仓库 历史上大家用的是 github.com/golang/mock,Google 在 2023 年 6 月归档了那个仓库,Uber fork 出来继续维护,成了社区事实上的接班人。新项目直接用 Uber 维护的这个 —— go.uber.org/mock ,API 跟老版本兼容,导入路径换一下即可。
mockgen 生成桩代码 gomock 不是运行时反射生成 mock,而是靠代码生成。先装工具:
1 2 go install go.uber.org/mock/mockgen@latest
然后在定义接口的文件里写一行 go:generate 指令:
1 2 3 4 5 6 7 type UserRepo interface { FindByID(ctx context.Context, id int ) (*User, error ) }
go generate ./... 跑一遍就有了 MockUserRepo。
三种最常用的期望写法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func TestDisplayName_fallbackToEmail (t *testing.T) { ctrl := gomock.NewController(t) repo := NewMockUserRepo(ctrl) repo.EXPECT(). FindByID(gomock.Any(), 42 ). Return(&User{ID: 42 , Name: "" , Email: "u42@example.com" }, nil ). Times(1 ) svc := NewUserService(repo) got, err := svc.DisplayName(context.Background(), 42 ) require.NoError(t, err) require.Equal(t, "u42@example.com" , got) }
需要根据入参动态决定返回什么,用 DoAndReturn:
1 2 3 4 5 6 7 8 repo.EXPECT(). FindByID(gomock.Any(), gomock.Any()). DoAndReturn(func (ctx context.Context, id int ) (*User, error ) { return &User{ID: id, Name: fmt.Sprintf("user-%d" , id)}, nil }). AnyTimes()
期望没被满足会自动报错 老版本的 gomock 需要手写 defer ctrl.Finish() 才会校验。从 golang/mock v1.5 开始,只要 NewController 传的是 *testing.T,校验会自动挂到 t.Cleanup 上。实测设了期望却不调用:
1 2 3 4 5 === RUN TestMissingCallStillFails gomockfinish_test.go:15: 没有调用 FindByID,也没写 ctrl.Finish() controller.go:97: missing call(s) to *demo.MockUserRepo.FindByID(is anything, is anything) controller.go:97: aborting test due to missing call(s) --- FAIL: TestMissingCallStillFails (0.00s)
确实报了错。所以新代码里不用再写 defer ctrl.Finish()。
顺带一提,mock 只对接口有用。要让业务代码能被 mock,依赖必须以接口形式注入进来,而不是在函数内部直接 new 一个具体类型。这一点属于设计问题而不是测试问题,之前在 Go 接口设计 里聊过。
net/http/httptest:HTTP 两个方向都能测 net/http/httptest 是标准库的一部分,不用装。它解决两个方向的问题,很多人只知道其中一个。
方向一:测自己写的 handler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func TestHealthHandler (t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/health" , nil ) rec := httptest.NewRecorder() HealthHandler(rec, req) res := rec.Result() defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, "application/json" , res.Header.Get("Content-Type" )) body, _ := io.ReadAll(res.Body) require.JSONEq(t, `{"status":"ok"}` , string (body)) }
整个过程没有监听端口,也没有真实的 TCP 连接,就是直接调用 handler 函数,速度和普通单测一样。
方向二:给客户端代码造一个假的上游服务 这个方向用得更少,但更有价值。业务里调用第三方 HTTP 接口的代码,测的时候不能真的打过去,httptest.NewServer 能起一个只在本机监听、随用随关的真实 HTTP 服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func TestWeatherClient (t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { require.Equal(t, "/weather" , r.URL.Path) require.Equal(t, "beijing" , r.URL.Query().Get("city" )) w.Write([]byte (`{"temp": 26.5}` )) })) defer srv.Close() c := &WeatherClient{ BaseURL: srv.URL, HTTP: srv.Client(), } temp, err := c.Temp("beijing" ) require.NoError(t, err) require.InDelta(t, 26.5 , temp, 0.001 ) }
srv.Client() 在测 HTTPS 时特别有用:httptest.NewTLSServer 用的是自签证书,普通 http.Client 会因为证书校验失败而拒绝连接,srv.Client() 返回的客户端已经信任了那张证书。
go-sqlmock:不起数据库,测 SQL 层 repository 层的代码,逻辑往往就是”拼一条 SQL、扫描结果、映射成结构体”。想验证 SQL 拼对没有、sql.ErrNoRows 有没有被正确翻译成业务错误,起一个真数据库有点重。go-sqlmock 的做法是伪造一个 database/sql 的驱动:你的代码照常调 db.QueryRowContext,但底层不连任何数据库,而是拿你预设的期望来比对。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func TestSQLUserRepo_FindByID (t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() rows := sqlmock.NewRows([]string {"id" , "name" , "email" }). AddRow(1 , "Alice" , "alice@example.com" ) mock.ExpectQuery("SELECT id, name, email FROM users WHERE id = ?" ). WithArgs(1 ). WillReturnRows(rows) repo := &SQLUserRepo{db: db} u, err := repo.FindByID(context.Background(), 1 ) require.NoError(t, err) require.Equal(t, "Alice" , u.Name) require.NoError(t, mock.ExpectationsWereMet()) }
想测错误分支也很方便:
1 2 3 mock.ExpectQuery("SELECT id, name, email FROM users" ). WithArgs(99 ). WillReturnError(sql.ErrNoRows)
默认的匹配器是正则,SQL 里的括号会翻车 这是 go-sqlmock 最容易踩的地方:ExpectQuery 和 ExpectExec 的参数默认按正则表达式解析 ,不是按字符串相等比。SQL 里常见的 (、)、?、. 都是正则元字符。
实测一条最普通的 INSERT:
1 2 3 4 5 6 mock.ExpectExec("INSERT INTO users (name) VALUES (?)" ). WithArgs("Bob" ). WillReturnResult(sqlmock.NewResult(1 , 1 )) _, err := db.ExecContext(ctx, "INSERT INTO users (name) VALUES (?)" , "Bob" )
期望和实际执行的 SQL 一模一样,却匹配失败:
1 2 3 默认正则匹配器结果: err=ExecQuery: could not match actual sql: "INSERT INTO users (name) VALUES (?)" with expected regexp " INSERT INTO users (name) VALUES (?)"
原因是括号在正则里是分组,? 是”前一个字符可有可无”,整条期望被解析成了完全不同的模式。两种解法:
1 2 3 4 5 6 7 db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO users (name) VALUES (?)" ))
换成 QueryMatcherEqual 之后同一段代码就通过了。团队里如果没人指望用正则做模糊匹配,建议在项目里统一封装一个带 QueryMatcherEqual 的构造函数。
要注意 sqlmock 验证的是”你的代码发出了什么 SQL”,不验证这条 SQL 在真实数据库里跑不跑得通。SQL 语法写错、字段名拼错、索引没建,它一概发现不了。
testcontainers-go:需要真数据库的时候 sqlmock 管不了的部分——SQL 语法对不对、事务隔离级别的行为、数据库特有的函数——只能用真数据库测。testcontainers-go 的做法是在测试代码里直接起 Docker 容器,用完自动销毁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 func TestWithRealPostgres (t *testing.T) { ctx := t.Context() pg, err := postgres.Run(ctx, "postgres:17-alpine" , postgres.WithDatabase("testdb" ), postgres.WithUsername("test" ), postgres.WithPassword("test" ), testcontainers.WithWaitStrategy( wait.ForListeningPort("5432/tcp" ), ), ) testcontainers.CleanupContainer(t, pg) if err != nil { t.Fatal(err) } dsn, err := pg.ConnectionString(ctx, "sslmode=disable" ) if err != nil { t.Fatal(err) } db, err := sql.Open("pgx" , dsn) if err != nil { t.Fatal(err) } defer db.Close() if err := db.PingContext(ctx); err != nil { t.Fatal(err) } }
代价也很明确:必须有能用的 Docker 环境,而且慢 。拉镜像加启动容器通常是几秒到几十秒,不可能像单测那样每改一行代码就跑一遍。实践中的做法是把它单独打个标签隔离开:
1 2 go test ./... go test -tags=integration ./...
关于集成测试跟单元测试的边界到底在哪,之前专门写过一篇 单元、集成、契约、端到端 。
怎么选:按”要不要真依赖”往下走 这些库的关系不是竞品,是分工。一个项目里同时用四五个是很正常的。
flowchart TD
A["要测的这段代码"] --> B{"依赖外部东西吗"}
B -->|"纯逻辑,不依赖"| C["标准库 testing 表驱动<br/>断言啰嗦就加 testify"]
B -->|"依赖接口<br/>(repo、下游 client)"| D["go.uber.org/mock<br/>给接口打桩"]
B -->|"依赖 HTTP"| E{"测哪一端"}
B -->|"依赖数据库"| F{"要验证什么"}
E -->|"自己的 handler"| G["httptest.NewRecorder"]
E -->|"调用别人的接口"| H["httptest.NewServer<br/>造假上游"]
F -->|"SQL 拼得对不对"| I["go-sqlmock"]
F -->|"SQL 真能跑通吗"| J["testcontainers-go<br/>打 build tag 隔离"]
一张对照表:
库
补的是哪一块
什么时候不该用
testing(标准库)
用例组织、清理、虚拟时钟
没有不该用的情况
testify assert/require
断言,省掉手写 if 比对
团队约定不引三方断言库时
testify suite
一组用例共享复杂 setup
初始化只有一两行时,纯属增加心智负担
go.uber.org/mock
接口打桩、调用次数与参数校验
依赖不是接口时(先改设计)
httptest
HTTP 两端,标准库自带
没有不该用的情况
go-sqlmock
验证代码发出的 SQL
想验证 SQL 在真库能跑通时
testcontainers-go
起真容器做集成测试
没有 Docker 环境、或者追求快速反馈时
跑测试时值得加的几个参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 go test -race ./... go test -cover ./... go test -coverprofile=c.out ./... && go tool cover -func=c.out go test -count=1 ./... go test -run 'TestUserService/TestName' -v ./...
go tool cover -func 的输出长这样,能直接看出哪个函数还有分支没走到:
1 2 3 demo /store.go:25 : NewUserService 100 .0 %demo /store.go:27 : DisplayName 83 .3 %total : (statements) 94 .4 %
覆盖率是个参考指标而不是目标。83.3% 提示 DisplayName 还有分支没测到,这条信息有用;但把它硬拉到 100% 而写出一堆没有断言意义的用例,就本末倒置了。