从 JUnit 到 Go testing:单测、表驱动、Mock 怎么写?

本文为《Java工程师转Go实战》连载第 16 篇 / 共 20 篇
上一篇:gRPC 入门
下一篇:GMP 与 GC 面试必考


类比开场

Java 测试标配:JUnit 5 + Mockito + AssertJ + JaCoCo。

Go 测试哲学:标准库就够用——testing包 + 表驱动测试 + 接口 mock。没有注解魔法,没有 DI 容器初始化,测试启动飞快。


一、对照表

JavaGo说明
JUnit 5testing标准库内置,无需第三方
@Testfunc TestXxx(t *testing.T)函数名即测试声明
@BeforeEach/@AfterEacht.Cleanup()或测试内 setup/teardown
@BeforeAll/@AfterAllTestMain(m *testing.M)整个包级别
AssertJ / Hamcresttestify/assert+testify/require也可用标准库
Mockitogomock/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=service
funcTestCreateUser_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 ./...

📦 面试追问

  1. testing 包 benchmark 原理?
    框架自动调整b.N:先小 N 测耗时,逐步加大直到运行时间稳定(默认跑 1 秒),最终报告每次操作的平均时间。-benchtime=5s可调整。

  2. 怎么测私有函数?
    同包_test.go直接测(白盒);或只测公开 API(黑盒,用package xxx_test)。Go 社区更推荐后者。

  3. go test 并行规则?
    包间默认并行(-p控制数量);包内默认串行,除非调用t.Parallel()-parallel参数控制包内最大并行数。

  4. 怎么做 mock?
    Go 不依赖框架注入,而是接口 + 构造函数注入。测试时传 mock 实现。如果你的代码难以 mock,说明缺少接口抽象。

  5. Go 有 Spring 那样的 @MockBean 吗?
    没有。Go 测试不需要启动 IoC 容器——直接NewService(mockRepo)构造即可。这是 Go 测试快的核心原因。


💡 一句话总结

JUnit 的 @Test 换成 TestXxx,@ParameterizedTest 换成表驱动,Mockito 换成接口 mock + gomock——Go 测试更朴素、更快(没有容器启动),但同样能覆盖从单元到集成的所有场景。表驱动是 Go 考官最爱问的测试模式,务必掌握。


建议标签GolangJavaJUnit单元测试testingMock表驱动