Files
cs-note/hzh/GolangStar/Go语言单测/Go语言单测.md
T

312 lines
8.5 KiB
Markdown
Raw Normal View History

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