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