8.5 KiB
8.5 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-06-07 15:30 |
Go 单元测试
概述
单元测试是保障代码质量的基石。Go 内置了 testing 包和 go test 命令,配合丰富的第三方生态(GoConvey、testify、GoMock),形成了一套完整的测试体系。本文从基础到高级覆盖所有核心技能。
正文
为什么写测试?
[!question] 💭 思考 你花了三天写完一个函数,提交前觉得"应该没问题"——但上线后第一个用户就遇到了 panic。如果写了单测呢?
单元测试的核心价值:
- 防回归:修改旧代码时自动验证已有功能未被破坏
- 设计指导:可测试的代码通常意味着清晰的接口边界
- 文档作用:测试用例是最准确的"这个函数应该怎么用"的说明
- 信心保障:重构时敢放手改,因为测试会捕获遗漏
[!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() — 带输出注释的示例 |
基础测试
被测试代码
package gotest
func Factorial(n int) int {
if n <= 0 {
return 1
}
return n * Factorial(n-1)
}
测试代码
func TestFactorial(t *testing.T) {
result := Factorial(5)
if result != 120 {
t.Errorf("Factorial(5) = %d; want 120", result)
}
}
运行:
$ go test -v
=== RUN TestFactorial
--- PASS: TestFactorial (0.00s)
PASS
表格驱动测试(Table-Driven Tests)
[!tip] 💡 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)
}
})
}
}
运行结果:
=== RUN TestFactorial
=== RUN TestFactorial/zero
=== RUN TestFactorial/one
=== RUN TestFactorial/five
=== RUN TestFactorial/ten
--- PASS: TestFactorial (0.00s)
[!tip] 💡 可复用的表格驱动模板
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 风格测试
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 / ShouldNotBeNilnil 检查 ShouldContain / ShouldNotContain集合包含检查
Stub / Mock 框架
[!question] 💭 思考 要测试一个调用数据库的函数,难道每次都要启动真实的数据库吗?
测试的核心原则:隔离被测单元。外部依赖(DB、网络、文件系统)应被替换为可控的替代品。
Stub vs Mock 的区别
| 概念 | 目的 | 能力 | 复杂度 |
|---|---|---|---|
| Stub | 提供固定返回值 | 只模拟"结果" | 低 |
| Mock | 验证交互过程 | 检查结果 + 调用次数 + 参数 + 顺序 | 高 |
GoMock — 接口 Mock
// 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
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
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)
func BenchmarkFactorial(b *testing.B) {
for i := 0; i < b.N; i++ {
Factorial(10)
}
}
运行:
$ 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] 💡 测试编写指南
- 每个公共函数都应该有对应的测试
- 测试名描述场景而非函数名:
TestLogin_EmptyPasswordReturnsError而非TestLogin- 每个测试独立:不依赖其他测试的执行顺序
- 使用表格驱动:覆盖正常、边界、异常三类场景
- Mock 外部依赖:DB、HTTP、文件操作必须替换
- 测试失败信息要有可读性:用
t.Errorf("when X, expected Y, got Z")格式- 定期运行
go test -race ./...:检测并发 bug
关联笔记
- hzh/GolangStar/Go语言进阶/Goroutine — 并发测试需注意 race condition
- hzh/GolangStar/Go语言框架/gin — Gin 路由测试可用 httptest