--- tags: [go, golang, 单元测试, testing, Mock] create time: 2026-06-07 15:30 --- # Go 单元测试 ## 概述 单元测试是保障代码质量的基石。Go 内置了 `testing` 包和 `go test` 命令,配合丰富的第三方生态(GoConvey、testify、GoMock),形成了一套完整的测试体系。本文从基础到高级覆盖所有核心技能。 ## 正文 ### 为什么写测试? > [!question] 💭 思考 > 你花了三天写完一个函数,提交前觉得"应该没问题"——但上线后第一个用户就遇到了 panic。如果写了单测呢? 单元测试的核心价值: 1. **防回归**:修改旧代码时自动验证已有功能未被破坏 2. **设计指导**:可测试的代码通常意味着清晰的接口边界 3. **文档作用**:测试用例是最准确的"这个函数应该怎么用"的说明 4. **信心保障**:重构时敢放手改,因为测试会捕获遗漏 > [!warning] ⚠️ 常见误区 > - "项目太急没时间写测试"——短期省下的时间会在后期 bug 修复中加倍偿还 > - "测试代码不需要维护"——烂测试比没测试更糟糕(假阳性让人失去信任) > - "100% 覆盖率 = 好软件"——覆盖率为零的项目一定有问题,但 100% 不等于没有 bug ### 命名规范与目录结构 ``` myproject/ ├── gotest/ │ ├── example.go # 被测试代码 │ └── example_test.go # 测试代码(必须以 _test.go 结尾) ``` | 规则 | 说明 | |------|------| | 文件名 | `*_test.go` —— 只有这个后缀的文件才会被 `go test` 识别 | | 测试函数 | `func TestXxx(t *testing.T)` — Xxx 首字母不能是小写 | | 基准测试 | `func BenchmarkXxx(b *testing.B)` | | 示例函数 | `func ExampleXxx()` — 带输出注释的示例 | ### 基础测试 #### 被测试代码 ```go package gotest func Factorial(n int) int { if n <= 0 { return 1 } return n * Factorial(n-1) } ``` #### 测试代码 ```go func TestFactorial(t *testing.T) { result := Factorial(5) if result != 120 { t.Errorf("Factorial(5) = %d; want 120", result) } } ``` 运行: ```bash $ go test -v === RUN TestFactorial --- PASS: TestFactorial (0.00s) PASS ``` ### 表格驱动测试(Table-Driven Tests) > [!tip] 💡 Go 测试的黄金模板 > 表格驱动测试是 Go 社区最推崇的测试模式——一条用例定义 + 一个循环覆盖,简洁且易扩展。 ```go func TestFactorial(t *testing.T) { tests := []struct { name string input int expected int }{ {"zero", 0, 1}, {"one", 1, 1}, {"five", 5, 120}, {"ten", 10, 3628800}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 子测试,可单独运行 result := Factorial(tt.input) if result != tt.expected { t.Errorf("Factorial(%d) = %d; want %d", tt.input, result, tt.expected) } }) } } ``` 运行结果: ```bash === RUN TestFactorial === RUN TestFactorial/zero === RUN TestFactorial/one === RUN TestFactorial/five === RUN TestFactorial/ten --- PASS: TestFactorial (0.00s) ``` > [!tip] 💡 可复用的表格驱动模板 > ```go > func TestName(t *testing.T) { > type args struct { /* 参数结构 */ } > tests := []struct { > name string > args args > want 返回类型 > wantErr bool > }{ > {"正常情况", args{...}, 期望值, false}, > {"边界情况", args{...}, 期望值, false}, > {"错误情况", args{...}, 期望值, true}, > } > for _, tt := range tests { > t.Run(tt.name, func(t *testing.T) { > got, err := FunctionName(tt.args.xxx) > if (err != nil) != tt.wantErr { > t.Errorf("error state = %v", err) > return > } > if got != tt.want { > t.Errorf("got = %v, want %v", got, tt.want) > } > }) > } > } > ``` ### GoConvey — BDD 风格测试 ```go import ( . "github.com/smartystreets/goconvey/convey" "testing" ) func TestSlicesEqual(t *testing.T) { Convey("给定两个切片,比较它们是否相等", t, func() { Convey("内容相同时", func() { So(SlicesEqual([]int{1,2,3}, []int{1,2,3}), ShouldBeTrue) }) Convey("长度不同时", func() { So(SlicesEqual([]int{1,2}, []int{1,2,3}), ShouldBeFalse) }) Convey("元素不同时报错", func() { So(SlicesEqual([]int{1,3}, []int{1,2}), ShouldBeFalse) }) }) } ``` > [!note] 📝 断言速查 > | 断言 | 含义 | > |------|------| > | `ShouldBeTrue / ShouldBeFalse` | 布尔值判断 | > | `ShouldEqual / ShouldNotEqual` | 相等性判断 | > | `ShouldBeNil / ShouldNotBeNil` | nil 检查 | > | `ShouldContain / ShouldNotContain` | 集合包含检查 | ### Stub / Mock 框架 > [!question] 💭 思考 > 要测试一个调用数据库的函数,难道每次都要启动真实的数据库吗? 测试的核心原则:**隔离被测单元**。外部依赖(DB、网络、文件系统)应被替换为可控的替代品。 #### Stub vs Mock 的区别 | 概念 | 目的 | 能力 | 复杂度 | |------|------|------|--------| | **Stub** | 提供固定返回值 | 只模拟"结果" | 低 | | **Mock** | 验证交互过程 | 检查结果 + 调用次数 + 参数 + 顺序 | 高 | #### GoMock — 接口 Mock ```go // 1. 定义接口 type DataStore interface { Get(key string) ([]byte, error) Set(key string, value []byte) error } // 2. 用 mockgen 生成 Mock // mockgen -source=datastore.go -destination=mock_datastore.go // 3. 在测试中使用 func TestProcessor(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockDS := NewMockDataStore(ctrl) // 设置期望行为 mockDS.EXPECT().Get("user_1").Return([]byte(`{"name":"Alice"}`), nil).Times(1) mockDS.EXPECT().Set("cache_1", gomock.Any()).Return(nil).AnyTimes() proc := NewProcessor(mockDS) result := proc.Process("user_1") // 验证期望是否满足 // (ctrl 会自动检查) } ``` #### sqlmock — 数据库 Mock ```go func TestUserQuery(t *testing.T) { db, mock, _ := sqlmock.New() defer db.Close() // 模拟查询结果 rows := sqlmock.NewRows([]string{"id", "username"}). AddRow(1, "alice"). AddRow(2, "bob") mock.ExpectQuery("SELECT.*FROM users").WillReturnRows(rows) // 执行查询 results, _ := db.Query("SELECT id, username FROM users") // ... 验证结果 // 验证所有期望都已满足 mock.ExpectationsWereMet() } ``` #### httptest — HTTP 服务器 Mock ```go func TestHandler(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } req := httptest.NewRequest("GET", "/api/test", nil) w := httptest.NewRecorder() handler(w, req) resp := w.Result() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) } if !bytes.Contains(body, []byte(`"status"`)) { t.Errorf("body missing 'status' field: %s", body) } } ``` ### 基准测试(Benchmark) ```go func BenchmarkFactorial(b *testing.B) { for i := 0; i < b.N; i++ { Factorial(10) } } ``` 运行: ```bash $ go test -bench=. -benchmem BenchmarkFactorial-12 1000000000 0.29 ns/op 0 B/op 0 allocs/op ``` > [!tip] 💡 基准测试进阶技巧 > - `-benchmem`:显示内存分配信息 > - `-benchtime=3s`:指定测试时长 > - `b.ResetTimer()`:跳过初始化耗时,只计时核心逻辑 > - `b.RunParallel(...)`:并行基准测试 ### 最佳实践 Checklist > [!tip] 💡 测试编写指南 > 1. **每个公共函数都应该有对应的测试** > 2. **测试名描述场景而非函数名**:`TestLogin_EmptyPasswordReturnsError` 而非 `TestLogin` > 3. **每个测试独立**:不依赖其他测试的执行顺序 > 4. **使用表格驱动**:覆盖正常、边界、异常三类场景 > 5. **Mock 外部依赖**:DB、HTTP、文件操作必须替换 > 6. **测试失败信息要有可读性**:用 `t.Errorf("when X, expected Y, got Z")` 格式 > 7. **定期运行 `go test -race ./...`**:检测并发 bug ## 关联笔记 - [[hzh/GolangStar/Go语言进阶/Goroutine]] — 并发测试需注意 race condition - [[hzh/GolangStar/Go语言框架/gin]] — Gin 路由测试可用 httptest