本文为《Java工程师转Go实战》连载第 16 篇 / 共 20 篇
上一篇:gRPC 入门
下一篇:GMP 与 GC 面试必考
类比开场
Java 测试标配:JUnit 5 + Mockito + AssertJ + JaCoCo。
Go 测试哲学:标准库就够用——testing包 + 表驱动测试 + 接口 mock。没有注解魔法,没有 DI 容器初始化,测试启动飞快。
一、对照表
| Java | Go | 说明 |
|---|---|---|
| JUnit 5 | testing标准库 | 内置,无需第三方 |
@Test | func TestXxx(t *testing.T) | 函数名即测试声明 |
@BeforeEach/@AfterEach | t.Cleanup()或测试内 setup/teardown | |
@BeforeAll/@AfterAll | TestMain(m *testing.M) | 整个包级别 |
| AssertJ / Hamcrest | testify/assert+testify/require | 也可用标准库 |
| Mockito | gomock/testify/mock/ 手写 mock | 接口 mock |
@ParameterizedTest | 表驱动测试(Go 特色) | 强烈推荐 |
@SpringBootTest | 无等价(不需要启动 IoC 容器) | 手动组装依赖 |
| JaCoCo 覆盖率 | go test -cover | 内置 |
| JMH 微基准 | func BenchmarkXxx(b *testing.B) | 内置 |
二、基础单测
文件组织规则
internal/service/ ├── user_service.go # 源码 └── user_service_test.go # 测试(同包或 _test 外包)- 测试文件必须以
_test.go结尾 - 测试函数必须以
Test开头(大写 T) - 参数是
*testing.T
标准写法
packageserviceimport"testing"funcTestValidateName(t*testing.T){// 测试空名字应返回错误iferr:=ValidateName("");err==nil{t.Fatal("expected error for empty name, got nil")}// 测试正常名字应通过iferr:=ValidateName("张三");err!=nil{t.Fatalf("unexpected error for valid name: %v",err)}// 测试超长名字longName:=string(make([]byte,100))iferr:=ValidateName(longName);err==nil{t.Fatal("expected error for too long name")}}运行测试
# 运行当前包测试gotest# 运行所有包的测试(递归)gotest./...# 详细输出gotest-v./internal/service# 只跑某个测试gotest-v./internal/service-runTestValidateName# 跑匹配模式的子测试gotest-v-run"TestCreateUser/empty_name"# 覆盖率gotest-cover./... gotest-coverprofile=coverage.out ./... go tool cover-html=coverage.out# 浏览器查看三、表驱动测试(Go 核心测试模式)
这是 Go 最有特色的测试写法——比 JUnit 的@ParameterizedTest更灵活直观。
基础模板
funcTestAdd(t*testing.T){tests:=[]struct{namestringa,bintwantint}{{"positive numbers",1,2,3},{"zeros",0,0,0},{"negative",-1,1,0},{"large numbers",100000,200000,300000},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){got:=Add(tt.a,tt.b)ifgot!=tt.want{t.Errorf("Add(%d, %d) = %d, want %d",tt.a,tt.b,got,tt.want)}})}}带错误期望的表驱动
funcTestCreateUser(t*testing.T){svc:=NewUserService(newMockRepo())tests:=[]struct{namestringinput CreateUserReq wantErrerror}{{name:"valid user",input:CreateUserReq{Name:"张三",Email:"a@b.com"},wantErr:nil,},{name:"empty name",input:CreateUserReq{Name:"",Email:"a@b.com"},wantErr:ErrInvalidName,},{name:"invalid email",input:CreateUserReq{Name:"张三",Email:"not-email"},wantErr:ErrInvalidEmail,},{name:"name too long",input:CreateUserReq{Name:strings.Repeat("a",100),Email:"a@b.com"},wantErr:ErrInvalidName,},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){_,err:=svc.Create(context.Background(),tt.input)if!errors.Is(err,tt.wantErr){t.Errorf("got error %v, want %v",err,tt.wantErr)}})}}并行表驱动(加速 CI)
funcTestUserService_Create(t*testing.T){// ...for_,tt:=rangetests{tt:=tt// Go 1.22 之前必须t.Run(tt.name,func(t*testing.T){t.Parallel()// 每个子测试并行执行// ...})}}四、testify:断言库
assert vs require
import("testing""github.com/stretchr/testify/assert""github.com/stretchr/testify/require")funcTestUserProfile(t*testing.T){user,err:=svc.GetProfile(ctx,1001)// require:失败立即停止(后续断言不执行)require.NoError(t,err)require.NotNil(t,user)// assert:失败继续执行(收集所有失败)assert.Equal(t,"张三",user.Name)assert.Equal(t,28,user.Age)assert.NotEmpty(t,user.Email)assert.True(t,user.CreatedAt.Before(time.Now()))}常用断言方法
assert.Equal(t,expected,actual)// 相等assert.NotEqual(t,unexpected,actual)// 不等assert.Nil(t,obj)// nilassert.NotNil(t,obj)// 非 nilassert.True(t,condition)// 真assert.False(t,condition)// 假assert.Contains(t,"hello world","world")// 包含assert.Len(t,slice,3)// 长度assert.Empty(t,slice)// 空assert.Error(t,err)// 有错误assert.NoError(t,err)// 无错误assert.ErrorIs(t,err,ErrNotFound)// 错误链匹配assert.ErrorAs(t,err,&target)// 错误类型匹配assert.Panics(t,func(){panic("boom")})// 会 panicassert.Eventually(t,func()bool{// 异步断言returnisReady()},5*time.Second,100*time.Millisecond)五、Mock 接口(对标 Mockito)
方式 1:手写 mock(小项目首选)
// 定义接口typeUserRepointerface{Create(ctx context.Context,u*User)errorGetByID(ctx context.Context,idint64)(*User,error)}// 手写 mocktypemockUserRepostruct{createFnfunc(context.Context,*User)errorgetByIDFnfunc(context.Context,int64)(*User,error)}func(m*mockUserRepo)Create(ctx context.Context,u*User)error{ifm.createFn!=nil{returnm.createFn(ctx,u)}returnnil}func(m*mockUserRepo)GetByID(ctx context.Context,idint64)(*User,error){ifm.getByIDFn!=nil{returnm.getByIDFn(ctx,id)}returnnil,ErrNotFound}// 使用funcTestCreateUser_Success(t*testing.T){repo:=&mockUserRepo{createFn:func(ctx context.Context,u*User)error{u.ID=1// 模拟 DB 自增 IDreturnnil},}svc:=NewUserService(repo)user,err:=svc.Create(context.Background(),CreateUserReq{Name:"张三"})require.NoError(t,err)assert.Equal(t,int64(1),user.ID)assert.Equal(t,"张三",user.Name)}funcTestCreateUser_DBError(t*testing.T){repo:=&mockUserRepo{createFn:func(ctx context.Context,u*User)error{returnerrors.New("connection refused")},}svc:=NewUserService(repo)_,err:=svc.Create(context.Background(),CreateUserReq{Name:"张三"})assert.Error(t,err)assert.Contains(t,err.Error(),"connection refused")}方式 2:gomock(大项目、自动生成)
# 安装goinstallgo.uber.org/mock/mockgen@latest# 从接口生成 mockmockgen-source=internal/service/user_service.go-destination=internal/service/mock_repo_test.go-package=servicefuncTestCreateUser_WithGoMock(t*testing.T){ctrl:=gomock.NewController(t)deferctrl.Finish()repo:=NewMockUserRepo(ctrl)// 设置期望repo.EXPECT().Create(gomock.Any(),gomock.Any()).DoAndReturn(func(ctx context.Context,u*User)error{u.ID=1returnnil})svc:=NewUserService(repo)user,err:=svc.Create(context.Background(),CreateUserReq{Name:"张三"})require.NoError(t,err)assert.Equal(t,int64(1),user.ID)}Mock 选型建议
| 方式 | 适用 | 优点 | 缺点 |
|---|---|---|---|
| 手写 mock struct | 小项目、方法少 | 简单直观 | 方法多时代码量大 |
| gomock | 大项目 | 自动生成、验证调用次数 | 需要代码生成步骤 |
| testify/mock | 习惯 Mockito 的 | API 类似 Mockito | 运行时反射 |
六、HTTP Handler 测试
使用 httptest(不起真实端口)
import("net/http""net/http/httptest""strings""testing""github.com/gin-gonic/gin")funcsetupRouter()*gin.Engine{gin.SetMode(gin.TestMode)r:=gin.New()svc:=NewUserService(newMockRepo())h:=NewUserHandler(svc)r.POST("/api/users",h.Create)r.GET("/api/users/:id",h.Get)returnr}funcTestCreateUser_HTTP(t*testing.T){router:=setupRouter()body:=`{"name":"张三","email":"zhangsan@test.com"}`req:=httptest.NewRequest("POST","/api/users",strings.NewReader(body))req.Header.Set("Content-Type","application/json")w:=httptest.NewRecorder()router.ServeHTTP(w,req)assert.Equal(t,201,w.Code)varrespmap[string]interface{}json.Unmarshal(w.Body.Bytes(),&resp)assert.Equal(t,float64(0),resp["code"])}funcTestGetUser_NotFound(t*testing.T){router:=setupRouter()req:=httptest.NewRequest("GET","/api/users/999",nil)w:=httptest.NewRecorder()router.ServeHTTP(w,req)assert.Equal(t,404,w.Code)}七、TestMain:全局 Setup/Teardown
funcTestMain(m*testing.M){// ====== Setup(@BeforeAll)======db:=setupTestDB()setupTestData(db)// ====== 运行所有测试 ======code:=m.Run()// ====== Teardown(@AfterAll)======cleanupTestDB(db)os.Exit(code)}t.Cleanup():单测级别清理
funcTestWithTempFile(t*testing.T){f,err:=os.CreateTemp("","test-*")require.NoError(t,err)t.Cleanup(func(){os.Remove(f.Name())// 测试结束后自动清理})// 使用临时文件...}八、Benchmark 微基准测试
funcBenchmarkJSONMarshal(b*testing.B){user:=&User{ID:1,Name:"张三",Email:"a@b.com"}b.ResetTimer()// 排除 setup 时间fori:=0;i<b.N;i++{json.Marshal(user)}}funcBenchmarkJSONMarshal_Parallel(b*testing.B){user:=&User{ID:1,Name:"张三",Email:"a@b.com"}b.RunParallel(func(pb*testing.PB){forpb.Next(){json.Marshal(user)}})}gotest-bench=.-benchmem./...# 输出:# BenchmarkJSONMarshal-8 5000000 240 ns/op 128 B/op 2 allocs/op# BenchmarkJSONMarshal_Parallel-8 20000000 60 ns/op 128 B/op 2 allocs/op对标 JMH:
ns/op:每次操作耗时B/op:每次操作分配的字节allocs/op:每次操作的内存分配次数
九、集成测试:TestContainers
import("github.com/testcontainers/testcontainers-go""github.com/testcontainers/testcontainers-go/modules/mysql")funcTestUserRepo_Integration(t*testing.T){iftesting.Short(){t.Skip("skipping integration test")}ctx:=context.Background()// 启动 MySQL 容器container,err:=mysql.Run(ctx,"mysql:8.0",mysql.WithDatabase("test"),mysql.WithUsername("root"),mysql.WithPassword("test"),)require.NoError(t,err)t.Cleanup(func(){container.Terminate(ctx)})// 获取连接字符串dsn,_:=container.ConnectionString(ctx)db:=connectDB(dsn)// 运行迁移migrate(db)// 测试 Repositoryrepo:=NewUserRepository(db)t.Run("create and get",func(t*testing.T){user:=&User{Name:"测试用户",Email:"test@test.com"}err:=repo.Create(ctx,user)require.NoError(t,err)assert.Greater(t,user.ID,int64(0))found,err:=repo.GetByID(ctx,user.ID)require.NoError(t,err)assert.Equal(t,"测试用户",found.Name)})}# 跑集成测试(不跳过)gotest-v./internal/repository/...# 只跑单测(跳过集成测试)gotest-short./...十、测试最佳实践
| 建议 | 原因 |
|---|---|
测试文件_test.go同包 | 白盒测试,可访问私有函数 |
或用xxx_test外包名 | 黑盒测试,只测公开 API |
| 表驱动覆盖所有边界 | 一目了然、易扩展 |
t.Parallel()加速 CI | 包内并行执行 |
HTTP 测试用httptest | 不需要真实端口 |
| Mock 接口不 mock 实现 | 依赖注入 + 接口 = 可测试 |
CI 中开启-race | 检测 data race |
| 覆盖率看趋势不追 100% | 核心路径必测,getter/setter 不必 |
Makefile 集成
.PHONY: test test-cover lint test: go test -race -short ./... test-cover: go test -race -coverprofile=coverage.out ./... go tool cover -func=coverage.out test-integration: go test -race -v ./... lint: golangci-lint run ./...📦 面试追问
testing 包 benchmark 原理?
框架自动调整b.N:先小 N 测耗时,逐步加大直到运行时间稳定(默认跑 1 秒),最终报告每次操作的平均时间。-benchtime=5s可调整。怎么测私有函数?
同包_test.go直接测(白盒);或只测公开 API(黑盒,用package xxx_test)。Go 社区更推荐后者。go test 并行规则?
包间默认并行(-p控制数量);包内默认串行,除非调用t.Parallel()。-parallel参数控制包内最大并行数。怎么做 mock?
Go 不依赖框架注入,而是接口 + 构造函数注入。测试时传 mock 实现。如果你的代码难以 mock,说明缺少接口抽象。Go 有 Spring 那样的 @MockBean 吗?
没有。Go 测试不需要启动 IoC 容器——直接NewService(mockRepo)构造即可。这是 Go 测试快的核心原因。
💡 一句话总结
JUnit 的 @Test 换成 TestXxx,@ParameterizedTest 换成表驱动,Mockito 换成接口 mock + gomock——Go 测试更朴素、更快(没有容器启动),但同样能覆盖从单元到集成的所有场景。表驱动是 Go 考官最爱问的测试模式,务必掌握。
建议标签:GolangJavaJUnit单元测试testingMock表驱动