Translation is not yet available. Showing original content.
Go testing 详解
目录
1. testing 包基础
1.1 测试文件和函数命名规则
Go 测试遵循严格的命名约定:
- 测试文件以
_test.go结尾(如user_test.go) - 测试函数以
Test开头,接一个大写字母开头的名称 - 测试函数接受
*testing.T参数 - 测试文件和被测文件放在同一个包中
myproject/
├── math.go # 被测代码
├── math_test.go # 测试代码
├── user.go
└── user_test.go
1.2 第一个测试
// math.go
package mymath
// Add 返回两个整数的和
func Add(a, b int) int {
return a + b
}
// Abs 返回绝对值
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
// math_test.go
package mymath
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; 期望 5", result)
}
}
func TestAbs(t *testing.T) {
result := Abs(-5)
if result != 5 {
t.Errorf("Abs(-5) = %d; 期望 5", result)
}
}
运行测试:
# 运行当前包的所有测试
go test
# 显示详细输出
go test -v
# 运行匹配特定名称的测试
go test -run TestAdd
# 运行项目中所有包的测试
go test ./...
1.3 t.Error, t.Fatal, t.Log 的区别
package mymath
import "testing"
func TestErrorVsFatal(t *testing.T) {
// t.Log 输出日志信息(仅在 -v 模式或测试失败时显示)
t.Log("开始测试...")
// t.Error / t.Errorf 标记测试失败,但继续执行后续代码
result := Add(1, 1)
if result != 2 {
t.Errorf("Add(1,1) = %d; 期望 2", result)
}
t.Log("这行代码在 t.Error 后仍然会执行")
// t.Fatal / t.Fatalf 标记测试失败,并立即停止当前测试函数
if result == 0 {
t.Fatal("结果为零,无法继续后续验证")
// 下面的代码不会执行
}
t.Log("这行代码在 t.Fatal 未触发时才会执行")
}
// 何时用 Error,何时用 Fatal?
// - Error: 当后续的断言仍有意义时(想看到所有失败的断言)
// - Fatal: 当后续代码依赖于当前断言的结果(如 nil 检查后解引用)
func TestFatalForPrerequisite(t *testing.T) {
// 典型用法:对 error/nil 检查用 Fatal
result := Add(10, 20)
// 假设这里是某个可能返回 nil 的操作
if result == 0 {
t.Fatal("前置条件不满足,终止测试")
}
// 后续操作依赖 result 不为 0
t.Logf("结果: %d", result)
}
1.4 t.Helper
package mymath
import "testing"
// assertEqual 是一个测试辅助函数
// t.Helper() 让错误报告的行号指向调用者,而不是这个辅助函数内部
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 标记为辅助函数
if got != want {
t.Errorf("得到 %d; 期望 %d", got, want)
}
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWithHelper(t *testing.T) {
// 如果断言失败,错误报告会指向这一行,而不是 assertEqual 内部
assertEqual(t, Add(1, 2), 3)
assertEqual(t, Add(-1, 1), 0)
assertEqual(t, Abs(-10), 10)
}
1.5 t.Cleanup
package mymath
import (
"fmt"
"os"
"testing"
)
func TestWithCleanup(t *testing.T) {
// 创建临时文件
f, err := os.CreateTemp("", "test-*")
if err != nil {
t.Fatal(err)
}
// t.Cleanup 注册清理函数,测试结束时自动执行
// 类似 defer 但由测试框架管理,子测试结束时也会执行
t.Cleanup(func() {
f.Close()
os.Remove(f.Name())
fmt.Println("清理完毕:", f.Name())
})
// 在测试中使用临时文件
_, err = f.WriteString("test data")
if err != nil {
t.Fatal(err)
}
t.Log("临时文件:", f.Name())
}
1.6 t.Skip 跳过测试
package mymath
import (
"os"
"runtime"
"testing"
)
func TestOnlyOnLinux(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("此测试仅在 Linux 上运行")
}
t.Log("在 Linux 上执行特定测试...")
}
func TestRequiresDatabase(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("跳过:未设置 DATABASE_URL 环境变量")
}
t.Logf("连接数据库: %s", dsn)
}
func TestSlow(t *testing.T) {
if testing.Short() {
t.Skip("跳过慢速测试(使用 -short 标志时)")
}
// 运行耗时较长的测试...
t.Log("执行慢速测试...")
}
运行方式:
# 跳过慢速测试
go test -short
# 查看跳过原因
go test -v -short
2. 表格驱动测试
2.1 基本的表格驱动测试
表格驱动测试(Table-Driven Tests)是 Go 社区最推荐的测试模式:
package mymath
import "testing"
func TestAdd_TableDriven(t *testing.T) {
// 定义测试用例表
tests := []struct {
name string // 测试用例名称
a, b int // 输入
expected int // 期望输出
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -2, -3},
{"正负相加", -1, 1, 0},
{"零值", 0, 0, 0},
{"大数", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; 期望 %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
2.2 包含错误检查的表格测试
package mymath
import (
"errors"
"fmt"
"testing"
)
// Divide 整数除法
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
want int
wantErr bool // 是否期望错误
errMsg string // 期望的错误消息(可选)
}{
{
name: "正常除法",
a: 10, b: 3,
want: 3, wantErr: false,
},
{
name: "整除",
a: 10, b: 2,
want: 5, wantErr: false,
},
{
name: "除以零",
a: 10, b: 0,
want: 0, wantErr: true,
errMsg: "除数不能为零",
},
{
name: "负数除法",
a: -10, b: 3,
want: -3, wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
// 检查错误
if tt.wantErr {
if err == nil {
t.Fatal("期望错误,但没有返回错误")
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("错误消息 = %q; 期望 %q", err.Error(), tt.errMsg)
}
return // 有错误时不检查返回值
}
if err != nil {
t.Fatalf("不期望错误,但得到: %v", err)
}
// 检查结果
if got != tt.want {
t.Errorf("Divide(%d, %d) = %d; 期望 %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
// 使用 map 作为测试用例集合(无序但可读性好)
func TestDivide_Map(t *testing.T) {
tests := map[string]struct {
a, b int
want int
wantErr bool
}{
"10/2": {10, 2, 5, false},
"9/3": {9, 3, 3, false},
"1/0": {1, 0, 0, true},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Fatalf("Divide(%d, %d) error = %v, wantErr %v",
tt.a, tt.b, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
func init() {
_ = fmt.Sprintf // avoid unused import
}
3. 子测试 t.Run
3.1 子测试基础
package mymath
import "testing"
func TestAbs_Subtests(t *testing.T) {
// 子测试允许将相关测试组织在一起
t.Run("正数", func(t *testing.T) {
if Abs(5) != 5 {
t.Error("正数的绝对值应该等于自身")
}
})
t.Run("负数", func(t *testing.T) {
if Abs(-5) != 5 {
t.Error("负数的绝对值应该等于其相反数")
}
})
t.Run("零", func(t *testing.T) {
if Abs(0) != 0 {
t.Error("零的绝对值应该等于零")
}
})
}
运行特定子测试:
# 运行 TestAbs_Subtests 下的 "负数" 子测试
go test -run TestAbs_Subtests/负数 -v
# 支持正则表达式
go test -run "TestAbs_Subtests/正" -v
3.2 嵌套子测试与共享 setup
package mymath
import (
"testing"
)
// UserService 模拟服务
type UserService struct {
users map[string]int // name -> age
}
func NewUserService() *UserService {
return &UserService{
users: map[string]int{
"Alice": 30,
"Bob": 25,
},
}
}
func (s *UserService) GetAge(name string) (int, bool) {
age, ok := s.users[name]
return age, ok
}
func (s *UserService) AddUser(name string, age int) {
s.users[name] = age
}
func TestUserService(t *testing.T) {
// 顶层 setup: 所有子测试共享
svc := NewUserService()
t.Run("GetAge", func(t *testing.T) {
t.Run("存在的用户", func(t *testing.T) {
age, ok := svc.GetAge("Alice")
if !ok {
t.Fatal("用户 Alice 应该存在")
}
if age != 30 {
t.Errorf("Alice 的年龄 = %d; 期望 30", age)
}
})
t.Run("不存在的用户", func(t *testing.T) {
_, ok := svc.GetAge("Charlie")
if ok {
t.Error("用户 Charlie 不应该存在")
}
})
})
t.Run("AddUser", func(t *testing.T) {
svc.AddUser("Dave", 35)
age, ok := svc.GetAge("Dave")
if !ok {
t.Fatal("添加后用户 Dave 应该存在")
}
if age != 35 {
t.Errorf("Dave 的年龄 = %d; 期望 35", age)
}
})
}
3.3 并行子测试
package mymath
import (
"testing"
"time"
)
// slowOperation 模拟耗时操作
func slowOperation(input int) int {
time.Sleep(100 * time.Millisecond)
return input * 2
}
func TestParallelSubtests(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"double_1", 1, 2},
{"double_2", 2, 4},
{"double_5", 5, 10},
{"double_10", 10, 20},
}
for _, tt := range tests {
tt := tt // 重要!在 Go 1.22 之前需要创建循环变量的副本
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 标记为可并行执行
got := slowOperation(tt.input)
if got != tt.want {
t.Errorf("slowOperation(%d) = %d; want %d",
tt.input, got, tt.want)
}
})
}
// 所有并行子测试会在 TestParallelSubtests 返回前完成
}
注意:在 Go 1.22 之前,
for循环中的变量在每次迭代时共享同一地址。如果不使用tt := tt创建副本,并行子测试可能读到错误的值。Go 1.22+ 已修复此问题(循环变量按迭代创建)。
4. 基准测试 Benchmark
4.1 基准测试基础
package mymath
import "testing"
// 基准测试函数以 Benchmark 开头,参数是 *testing.B
func BenchmarkAdd(b *testing.B) {
// b.N 由测试框架动态调整,直到结果稳定
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
func BenchmarkAbs(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(-42)
}
}
运行基准测试:
# 运行所有基准测试
go test -bench=.
# 运行特定基准测试
go test -bench=BenchmarkAdd
# 运行多次以获得更稳定的结果
go test -bench=. -count=5
# 同时显示内存分配信息
go test -bench=. -benchmem
# 指定运行时间(默认1秒)
go test -bench=. -benchtime=3s
输出示例:
BenchmarkAdd-8 1000000000 0.2950 ns/op
BenchmarkAbs-8 1000000000 0.5100 ns/op
-8表示使用 8 个 CPU 核心1000000000是 b.N 的值(运行次数)0.2950 ns/op是每次操作的耗时
4.2 b.ResetTimer 和 b.StopTimer
package mymath
import (
"testing"
"time"
)
// 场景:基准测试需要准备数据,但准备时间不应计入基准
func BenchmarkWithSetup(b *testing.B) {
// 准备阶段(耗时的初始化)
data := make([]int, 10000)
for i := range data {
data[i] = i
}
time.Sleep(10 * time.Millisecond) // 模拟耗时初始化
// 重置计时器,排除准备阶段的时间
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 只测量这个操作的性能
sum := 0
for _, v := range data {
sum += v
}
}
}
func BenchmarkWithPause(b *testing.B) {
for i := 0; i < b.N; i++ {
// 暂停计时:准备每次迭代的输入
b.StopTimer()
data := make([]int, 100)
for j := range data {
data[j] = j
}
b.StartTimer()
// 只测量这个操作
sum := 0
for _, v := range data {
sum += v
}
_ = sum
}
}
4.3 b.ReportAllocs 和内存基准
package mymath
import (
"fmt"
"strings"
"testing"
)
// 比较字符串拼接的不同方式
func concatWithPlus(strs []string) string {
result := ""
for _, s := range strs {
result += s
}
return result
}
func concatWithBuilder(strs []string) string {
var b strings.Builder
for _, s := range strs {
b.WriteString(s)
}
return b.String()
}
func concatWithJoin(strs []string) string {
return strings.Join(strs, "")
}
func BenchmarkConcatPlus(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs() // 报告内存分配
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithPlus(strs)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithBuilder(strs)
}
}
func BenchmarkConcatJoin(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithJoin(strs)
}
}
运行结果示例(go test -bench=BenchmarkConcat -benchmem):
BenchmarkConcatPlus-8 5000 300000 ns/op 530000 B/op 99 allocs/op
BenchmarkConcatBuilder-8 200000 8000 ns/op 2048 B/op 8 allocs/op
BenchmarkConcatJoin-8 200000 7500 ns/op 1024 B/op 1 allocs/op
可以清楚看到 + 拼接的内存分配远高于 Builder 和 Join。
4.4 子基准测试
package mymath
import (
"fmt"
"testing"
)
func BenchmarkAbs_Sizes(b *testing.B) {
inputs := []int{1, 100, -1, -100, 0}
for _, input := range inputs {
b.Run(fmt.Sprintf("input=%d", input), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(input)
}
})
}
}
go test -bench="BenchmarkAbs_Sizes" -v
# BenchmarkAbs_Sizes/input=1-8 1000000000 0.30 ns/op
# BenchmarkAbs_Sizes/input=100-8 1000000000 0.30 ns/op
# BenchmarkAbs_Sizes/input=-1-8 1000000000 0.52 ns/op
# ...
5. 测试覆盖率
5.1 基本覆盖率
# 查看覆盖率百分比
go test -cover
# 输出示例:
# PASS
# coverage: 85.7% of statements
# ok mypackage 0.003s
# 生成覆盖率 profile 文件
go test -coverprofile=coverage.out
# 在终端查看每个函数的覆盖率
go tool cover -func=coverage.out
# 输出示例:
# mypackage/math.go:3: Add 100.0%
# mypackage/math.go:8: Abs 80.0%
# mypackage/math.go:15: Divide 100.0%
# total: (statements) 93.3%
# 在浏览器中查看可视化覆盖率报告(绿色=已覆盖,红色=未覆盖)
go tool cover -html=coverage.out
5.2 覆盖率模式
# 默认模式:set(行是否被执行)
go test -coverprofile=coverage.out -covermode=set
# count 模式:每行执行了几次
go test -coverprofile=coverage.out -covermode=count
# atomic 模式:count 的线程安全版本,适合 -race
go test -coverprofile=coverage.out -covermode=atomic
5.3 多包覆盖率
# 计算所有包的覆盖率
go test -coverprofile=coverage.out ./...
# 包含所有包(即使没有测试文件的包也统计)
go test -coverpkg=./... -coverprofile=coverage.out ./...
5.4 在 CI 中使用覆盖率
#!/bin/bash
# ci_test.sh - CI 中的测试脚本
# 运行测试并生成覆盖率报告
go test -coverprofile=coverage.out -covermode=atomic ./...
# 提取总覆盖率
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "总覆盖率: ${COVERAGE}%"
# 检查覆盖率是否达到最低要求(如 80%)
MIN_COVERAGE=80.0
if [ "$(echo "$COVERAGE < $MIN_COVERAGE" | bc)" -eq 1 ]; then
echo "覆盖率 ${COVERAGE}% 低于最低要求 ${MIN_COVERAGE}%"
exit 1
fi
6. TestMain
6.1 TestMain 基础
TestMain 可以控制测试的整体生命周期,在所有测试执行前后做 setup 和 teardown。
package mymath
import (
"fmt"
"os"
"testing"
)
// TestMain 每个包最多只能有一个
// 它在所有测试之前运行,控制测试流程
func TestMain(m *testing.M) {
// ===== Setup: 在所有测试之前执行 =====
fmt.Println(">>> 全局 Setup: 初始化测试环境")
// 例如:创建测试数据库、启动测试服务器、加载测试数据等
// ===== 运行所有测试 =====
// m.Run() 运行所有 Test* 函数,返回退出码
exitCode := m.Run()
// ===== Teardown: 在所有测试之后执行 =====
fmt.Println(">>> 全局 Teardown: 清理测试环境")
// 例如:删除测试数据库、关闭连接等
// 必须调用 os.Exit,否则测试结果不会正确报告
os.Exit(exitCode)
}
func TestExample1(t *testing.T) {
t.Log("运行测试1")
}
func TestExample2(t *testing.T) {
t.Log("运行测试2")
}
输出:
>>> 全局 Setup: 初始化测试环境
=== RUN TestExample1
运行测试1
--- PASS: TestExample1
=== RUN TestExample2
运行测试2
--- PASS: TestExample2
>>> 全局 Teardown: 清理测试环境
PASS
6.2 实际应用:数据库测试
package db_test
import (
"database/sql"
"fmt"
"os"
"testing"
_ "github.com/mattn/go-sqlite3" // SQLite 驱动
)
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup: 创建测试数据库
var err error
testDB, err = sql.Open("sqlite3", ":memory:")
if err != nil {
fmt.Fprintf(os.Stderr, "无法创建测试数据库: %v\n", err)
os.Exit(1)
}
// 创建表结构
_, err = testDB.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`)
if err != nil {
fmt.Fprintf(os.Stderr, "无法创建表: %v\n", err)
os.Exit(1)
}
// 运行测试
exitCode := m.Run()
// Teardown: 关闭数据库
testDB.Close()
os.Exit(exitCode)
}
func TestInsertUser(t *testing.T) {
// 每个测试前清理数据(或使用事务回滚)
t.Cleanup(func() {
testDB.Exec("DELETE FROM users")
})
_, err := testDB.Exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
"Alice", "alice@example.com",
)
if err != nil {
t.Fatalf("插入用户失败: %v", err)
}
var count int
err = testDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if count != 1 {
t.Errorf("用户数量 = %d; 期望 1", count)
}
}
func TestUniqueEmail(t *testing.T) {
t.Cleanup(func() {
testDB.Exec("DELETE FROM users")
})
testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
"Alice", "alice@example.com")
// 插入重复邮箱应该失败
_, err := testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
"Bob", "alice@example.com")
if err == nil {
t.Error("期望唯一约束错误,但没有返回错误")
}
}
7. httptest 包
7.1 测试 HTTP Handler
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ========== 被测代码 ==========
// HealthHandler 健康检查
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
// GreetHandler 问候接口
func GreetHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "缺少 name 参数", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "你好, %s!", name)
}
// CreateUserHandler 创建用户
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "只接受 POST 请求", http.StatusMethodNotAllowed)
return
}
var user struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "无效的 JSON", http.StatusBadRequest)
return
}
if user.Name == "" || user.Email == "" {
http.Error(w, "name 和 email 不能为空", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"name": user.Name,
"email": user.Email,
})
}
// ========== 测试代码 ==========
func TestHealthHandler(t *testing.T) {
// 创建请求
req := httptest.NewRequest(http.MethodGet, "/health", nil)
// 创建 ResponseRecorder(模拟的 http.ResponseWriter)
rr := httptest.NewRecorder()
// 调用 handler
HealthHandler(rr, req)
// 检查状态码
if rr.Code != http.StatusOK {
t.Errorf("状态码 = %d; 期望 %d", rr.Code, http.StatusOK)
}
// 检查 Content-Type
contentType := rr.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %s; 期望 application/json", contentType)
}
// 检查响应体
var body map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应体失败: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %s; 期望 ok", body["status"])
}
}
func TestGreetHandler(t *testing.T) {
tests := []struct {
name string
queryName string
wantCode int
wantBody string
}{
{
name: "正常请求",
queryName: "Alice",
wantCode: http.StatusOK,
wantBody: "你好, Alice!",
},
{
name: "缺少参数",
queryName: "",
wantCode: http.StatusBadRequest,
wantBody: "缺少 name 参数\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := "/greet"
if tt.queryName != "" {
url += "?name=" + tt.queryName
}
req := httptest.NewRequest(http.MethodGet, url, nil)
rr := httptest.NewRecorder()
GreetHandler(rr, req)
if rr.Code != tt.wantCode {
t.Errorf("状态码 = %d; 期望 %d", rr.Code, tt.wantCode)
}
if rr.Body.String() != tt.wantBody {
t.Errorf("响应体 = %q; 期望 %q", rr.Body.String(), tt.wantBody)
}
})
}
}
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
method string
body string
wantCode int
}{
{
name: "成功创建",
method: http.MethodPost,
body: `{"name":"Alice","email":"alice@example.com"}`,
wantCode: http.StatusCreated,
},
{
name: "错误的方法",
method: http.MethodGet,
body: "",
wantCode: http.StatusMethodNotAllowed,
},
{
name: "无效JSON",
method: http.MethodPost,
body: "not json",
wantCode: http.StatusBadRequest,
},
{
name: "缺少字段",
method: http.MethodPost,
body: `{"name":"Alice"}`,
wantCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body *strings.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(tt.method, "/users", body)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateUserHandler(rr, req)
if rr.Code != tt.wantCode {
t.Errorf("状态码 = %d; 期望 %d\n响应: %s",
rr.Code, tt.wantCode, rr.Body.String())
}
})
}
}
7.2 httptest.NewServer 测试完整的 HTTP 流程
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestWithTestServer(t *testing.T) {
// 创建一个真正监听端口的测试服务器
mux := http.NewServeMux()
mux.HandleFunc("/health", HealthHandler)
mux.HandleFunc("/greet", GreetHandler)
server := httptest.NewServer(mux)
defer server.Close() // 测试结束后关闭服务器
t.Logf("测试服务器地址: %s", server.URL)
// 像真正的客户端一样发送请求
t.Run("health", func(t *testing.T) {
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("状态码 = %d; 期望 200", resp.StatusCode)
}
var body map[string]string
json.NewDecoder(resp.Body).Decode(&body)
if body["status"] != "ok" {
t.Errorf("status = %s; 期望 ok", body["status"])
}
})
t.Run("greet", func(t *testing.T) {
resp, err := http.Get(server.URL + "/greet?name=World")
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if string(bodyBytes) != "你好, World!" {
t.Errorf("响应 = %q; 期望 %q", string(bodyBytes), "你好, World!")
}
})
}
// 测试 TLS 服务器
func TestWithTLSServer(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(HealthHandler))
defer server.Close()
// server.Client() 返回配置好 TLS 证书的 http.Client
client := server.Client()
resp, err := client.Get(server.URL + "/health")
if err != nil {
t.Fatalf("TLS 请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("状态码 = %d; 期望 200", resp.StatusCode)
}
}
8. Mock 和接口测试技巧
8.1 使用接口实现 Mock
Go 推荐通过接口来实现依赖注入和 Mock,而不是使用复杂的 Mock 框架。
package service
import (
"errors"
"testing"
)
// ========== 定义接口 ==========
// UserRepository 用户数据访问接口
type UserRepository interface {
GetByID(id int) (*User, error)
Save(user *User) error
Delete(id int) error
}
// EmailSender 邮件发送接口
type EmailSender interface {
Send(to, subject, body string) error
}
type User struct {
ID int
Name string
Email string
}
// ========== 被测服务 ==========
// UserService 依赖接口而不是具体实现
type UserService struct {
repo UserRepository
email EmailSender
}
func NewUserService(repo UserRepository, email EmailSender) *UserService {
return &UserService{repo: repo, email: email}
}
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.GetByID(id)
}
func (s *UserService) CreateUser(name, email string) (*User, error) {
user := &User{Name: name, Email: email}
if err := s.repo.Save(user); err != nil {
return nil, err
}
// 发送欢迎邮件
err := s.email.Send(email, "欢迎", "欢迎加入!")
if err != nil {
// 邮件发送失败不影响用户创建
// 实际项目中可能记录日志或放入队列
}
return user, nil
}
// ========== Mock 实现 ==========
// MockUserRepo 模拟用户仓库
type MockUserRepo struct {
users map[int]*User
saveErr error // 可以注入错误
saveCalled bool
}
func NewMockUserRepo() *MockUserRepo {
return &MockUserRepo{
users: make(map[int]*User),
}
}
func (m *MockUserRepo) GetByID(id int) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, errors.New("用户未找到")
}
return user, nil
}
func (m *MockUserRepo) Save(user *User) error {
m.saveCalled = true
if m.saveErr != nil {
return m.saveErr
}
user.ID = len(m.users) + 1
m.users[user.ID] = user
return nil
}
func (m *MockUserRepo) Delete(id int) error {
delete(m.users, id)
return nil
}
// MockEmailSender 模拟邮件发送
type MockEmailSender struct {
sentEmails []struct {
To, Subject, Body string
}
sendErr error
}
func (m *MockEmailSender) Send(to, subject, body string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sentEmails = append(m.sentEmails, struct {
To, Subject, Body string
}{to, subject, body})
return nil
}
// ========== 测试 ==========
func TestUserService_GetUser(t *testing.T) {
repo := NewMockUserRepo()
repo.users[1] = &User{ID: 1, Name: "Alice", Email: "alice@test.com"}
svc := NewUserService(repo, &MockEmailSender{})
t.Run("用户存在", func(t *testing.T) {
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("不期望错误: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %s; 期望 Alice", user.Name)
}
})
t.Run("用户不存在", func(t *testing.T) {
_, err := svc.GetUser(999)
if err == nil {
t.Fatal("期望错误,但没有返回")
}
})
}
func TestUserService_CreateUser(t *testing.T) {
t.Run("成功创建并发送邮件", func(t *testing.T) {
repo := NewMockUserRepo()
emailer := &MockEmailSender{}
svc := NewUserService(repo, emailer)
user, err := svc.CreateUser("Bob", "bob@test.com")
if err != nil {
t.Fatalf("创建失败: %v", err)
}
if user.ID == 0 {
t.Error("用户ID不应为0")
}
if !repo.saveCalled {
t.Error("Save 应该被调用")
}
if len(emailer.sentEmails) != 1 {
t.Errorf("发送了 %d 封邮件; 期望 1", len(emailer.sentEmails))
}
if emailer.sentEmails[0].To != "bob@test.com" {
t.Errorf("邮件发送给 %s; 期望 bob@test.com",
emailer.sentEmails[0].To)
}
})
t.Run("保存失败", func(t *testing.T) {
repo := NewMockUserRepo()
repo.saveErr = errors.New("数据库错误")
svc := NewUserService(repo, &MockEmailSender{})
_, err := svc.CreateUser("Bob", "bob@test.com")
if err == nil {
t.Fatal("期望错误")
}
})
t.Run("邮件失败不影响创建", func(t *testing.T) {
repo := NewMockUserRepo()
emailer := &MockEmailSender{sendErr: errors.New("SMTP错误")}
svc := NewUserService(repo, emailer)
user, err := svc.CreateUser("Charlie", "charlie@test.com")
if err != nil {
t.Fatalf("用户创建不应失败: %v", err)
}
if user.Name != "Charlie" {
t.Errorf("Name = %s; 期望 Charlie", user.Name)
}
})
}
8.2 函数类型作为轻量 Mock
package service
import (
"fmt"
"testing"
)
// Fetcher 使用函数类型定义接口(更轻量)
type Fetcher func(url string) ([]byte, error)
// CachedFetcher 带缓存的获取器
type CachedFetcher struct {
fetch Fetcher
cache map[string][]byte
}
func NewCachedFetcher(fetch Fetcher) *CachedFetcher {
return &CachedFetcher{
fetch: fetch,
cache: make(map[string][]byte),
}
}
func (cf *CachedFetcher) Get(url string) ([]byte, error) {
if data, ok := cf.cache[url]; ok {
return data, nil // 缓存命中
}
data, err := cf.fetch(url)
if err != nil {
return nil, fmt.Errorf("获取 %s: %w", url, err)
}
cf.cache[url] = data
return data, nil
}
func TestCachedFetcher(t *testing.T) {
callCount := 0
// 直接用闭包作为 Mock
mockFetch := Fetcher(func(url string) ([]byte, error) {
callCount++
return []byte("response from " + url), nil
})
cf := NewCachedFetcher(mockFetch)
// 第一次调用:应该调用 fetch
data, err := cf.Get("https://example.com")
if err != nil {
t.Fatal(err)
}
if string(data) != "response from https://example.com" {
t.Errorf("响应 = %s", string(data))
}
if callCount != 1 {
t.Errorf("fetch 被调用 %d 次; 期望 1", callCount)
}
// 第二次调用相同 URL:应该命中缓存,不调用 fetch
_, err = cf.Get("https://example.com")
if err != nil {
t.Fatal(err)
}
if callCount != 1 {
t.Errorf("fetch 被调用 %d 次; 期望仍为 1(缓存命中)", callCount)
}
// 不同 URL:应该再次调用 fetch
_, err = cf.Get("https://other.com")
if err != nil {
t.Fatal(err)
}
if callCount != 2 {
t.Errorf("fetch 被调用 %d 次; 期望 2", callCount)
}
}
8.3 接口设计的测试原则
package service
import "testing"
// 测试技巧:在消费者包中定义小接口
// 不要在提供者包中定义大接口
// 差的做法:定义一个大而全的接口
// type BigRepository interface {
// GetByID(id int) (*User, error)
// GetByEmail(email string) (*User, error)
// GetAll() ([]*User, error)
// Save(user *User) error
// Update(user *User) error
// Delete(id int) error
// Count() (int, error)
// ... 更多方法
// }
// 好的做法:按需定义小接口(接口隔离原则)
type UserGetter interface {
GetByID(id int) (*User, error)
}
type UserSaver interface {
Save(user *User) error
}
// 函数只依赖它真正需要的方法
func getUserName(getter UserGetter, id int) (string, error) {
user, err := getter.GetByID(id)
if err != nil {
return "", err
}
return user.Name, nil
}
// 测试时只需要 mock 用到的方法
type simpleGetter struct {
user *User
err error
}
func (g *simpleGetter) GetByID(id int) (*User, error) {
return g.user, g.err
}
func TestGetUserName(t *testing.T) {
// Mock 非常简单,只需实现一个方法
getter := &simpleGetter{
user: &User{ID: 1, Name: "Alice"},
}
name, err := getUserName(getter, 1)
if err != nil {
t.Fatal(err)
}
if name != "Alice" {
t.Errorf("name = %s; want Alice", name)
}
}
9. 模糊测试 Fuzz
9.1 模糊测试基础(Go 1.18+)
模糊测试(Fuzz Testing)自动生成大量Random输入来发现边界情况和潜在的 bug。
package mymath
import (
"testing"
"unicode/utf8"
)
// Reverse 反转字符串(按字符而非字节)
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// FuzzReverse 模糊测试函数以 Fuzz 开头
func FuzzReverse(f *testing.F) {
// 添加种子语料库(seed corpus)
// 模糊测试引擎会基于这些种子生成更多测试输入
f.Add("hello")
f.Add("世界")
f.Add("")
f.Add("a")
f.Add("Hello, 世界! 🌍")
// f.Fuzz 接收的函数参数类型必须与 f.Add 的参数类型一致
f.Fuzz(func(t *testing.T, original string) {
// 对每个自动生成的输入执行测试
// 属性1: 反转两次应该得到原始字符串
reversed := Reverse(original)
doubleReversed := Reverse(reversed)
if original != doubleReversed {
t.Errorf("反转两次不等于原始值:\n原始: %q\n反转: %q\n双反: %q",
original, reversed, doubleReversed)
}
// 属性2: 反转后的字符串长度应该与原始相同
if utf8.RuneCountInString(original) != utf8.RuneCountInString(reversed) {
t.Errorf("长度不一致:\n原始: %q (len=%d)\n反转: %q (len=%d)",
original, utf8.RuneCountInString(original),
reversed, utf8.RuneCountInString(reversed))
}
// 属性3: 反转后的字符串应该是有效的 UTF-8
if !utf8.ValidString(reversed) {
t.Errorf("反转后不是有效的 UTF-8: %q", reversed)
}
})
}
运行模糊测试:
# 仅运行种子语料库(不做模糊测试)
go test -run FuzzReverse
# 运行模糊测试(持续运行直到找到 bug 或被中断)
go test -fuzz FuzzReverse
# 限制模糊测试运行时间
go test -fuzz FuzzReverse -fuzztime 30s
# 限制模糊测试迭代次数
go test -fuzz FuzzReverse -fuzztime 1000x
9.2 支持的参数类型
模糊测试的 f.Add 和 f.Fuzz 支持以下类型:
package mymath
import "testing"
// 所有支持的参数类型
func FuzzAllTypes(f *testing.F) {
f.Add(
"string", // string
[]byte("bytes"), // []byte
int(42), // int
int8(8), // int8
int16(16), // int16
int32(32), // int32
int64(64), // int64
uint(42), // uint
uint8(8), // uint8
uint16(16), // uint16
uint32(32), // uint32
uint64(64), // uint64
float32(3.14), // float32
float64(2.718), // float64
rune('A'), // rune (= int32)
byte(0xFF), // byte (= uint8)
true, // bool
)
f.Fuzz(func(t *testing.T,
s string, b []byte,
i int, i8 int8, i16 int16, i32 int32, i64 int64,
u uint, u8 uint8, u16 uint16, u32 uint32, u64 uint64,
f32 float32, f64 float64,
r rune, by byte, bl bool,
) {
// 使用自动生成的各类型值进行测试
_ = s
_ = b
})
}
9.3 实际应用:测试 JSON 解析
package mymath
import (
"encoding/json"
"testing"
)
// Config 应用配置
type Config struct {
Name string `json:"name"`
Port int `json:"port"`
Debug bool `json:"debug"`
}
// ParseConfig 解析配置 JSON
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func FuzzParseConfig(f *testing.F) {
// 种子:有效的 JSON
f.Add([]byte(`{"name":"app","port":8080,"debug":true}`))
f.Add([]byte(`{}`))
f.Add([]byte(`{"name":""}`))
// 种子:无效的输入
f.Add([]byte(`invalid json`))
f.Add([]byte(``))
f.Add([]byte(`null`))
f.Fuzz(func(t *testing.T, data []byte) {
cfg, err := ParseConfig(data)
if err != nil {
// 解析失败是正常的(对于Random输入)
// 关键是不能 panic
return
}
// 如果解析成功,重新序列化应该不 panic
reEncoded, err := json.Marshal(cfg)
if err != nil {
t.Errorf("重新编码失败: %v", err)
}
// 重新解析应该成功
var cfg2 Config
if err := json.Unmarshal(reEncoded, &cfg2); err != nil {
t.Errorf("重新解析失败: %v", err)
}
// 属性:name 字段应该保持一致
if cfg.Name != cfg2.Name {
t.Errorf("Name 不一致: %q vs %q", cfg.Name, cfg2.Name)
}
})
}
9.4 模糊测试发现 bug 后的工作流
# 1. 运行模糊测试
go test -fuzz FuzzReverse -fuzztime 1m
# 如果发现 bug,测试框架会将导致失败的输入保存到
# testdata/fuzz/FuzzReverse/ 目录下
# 2. 查看失败的输入
cat testdata/fuzz/FuzzReverse/xxxxxxxxx
# 3. 修复 bug 后,再次运行测试
# 种子语料库中保存的失败用例会自动成为回归测试
go test -run FuzzReverse
# 4. 继续模糊测试确认修复
go test -fuzz FuzzReverse -fuzztime 1m
9.5 模糊测试实用示例:URL 解析
package mymath
import (
"net/url"
"testing"
)
func FuzzURLParse(f *testing.F) {
f.Add("https://example.com/path?query=value#fragment")
f.Add("http://localhost:8080")
f.Add("ftp://user:pass@host/dir")
f.Add("/relative/path")
f.Add("")
f.Fuzz(func(t *testing.T, rawURL string) {
// 解析 URL
parsed, err := url.Parse(rawURL)
if err != nil {
return // 无效 URL 是可接受的
}
// 属性:重新编码后再解析应该得到相同结果
reEncoded := parsed.String()
reParsed, err := url.Parse(reEncoded)
if err != nil {
t.Errorf("重新编码的 URL 无法解析: %q -> %q -> error: %v",
rawURL, reEncoded, err)
return
}
// 关键字段应该一致
if parsed.Scheme != reParsed.Scheme {
t.Errorf("Scheme 不一致: %q vs %q", parsed.Scheme, reParsed.Scheme)
}
if parsed.Host != reParsed.Host {
t.Errorf("Host 不一致: %q vs %q", parsed.Host, reParsed.Host)
}
if parsed.Path != reParsed.Path {
t.Errorf("Path 不一致: %q vs %q", parsed.Path, reParsed.Path)
}
})
}
总结
| 功能 | 核心用法 | 运行命令 |
|---|---|---|
| 单元测试 | func TestXxx(t *testing.T) |
go test -v |
| 表格驱动 | tests := []struct{...} + t.Run |
go test -run TestXxx |
| 子测试 | t.Run("name", func(t *testing.T){...}) |
go test -run TestXxx/sub |
| 并行测试 | t.Parallel() |
go test -parallel 4 |
| 基准测试 | func BenchmarkXxx(b *testing.B) |
go test -bench=. |
| 内存分析 | b.ReportAllocs() |
go test -bench=. -benchmem |
| 覆盖率 | -cover / -coverprofile |
go test -cover |
| TestMain | func TestMain(m *testing.M) |
自动调用 |
| HTTP 测试 | httptest.NewRequest + httptest.NewRecorder |
go test -v |
| 测试服务器 | httptest.NewServer(handler) |
go test -v |
| Mock | 定义接口 + 实现 Mock 结构体 | go test -v |
| 模糊测试 | func FuzzXxx(f *testing.F) |
go test -fuzz FuzzXxx |
常用命令速查
# 运行所有测试
go test ./...
# 详细输出
go test -v ./...
# 跳过慢测试
go test -short ./...
# 检测数据竞争
go test -race ./...
# 覆盖率报告
go test -coverprofile=c.out ./... && go tool cover -html=c.out
# 基准测试
go test -bench=. -benchmem ./...
# 模糊测试(30秒)
go test -fuzz=FuzzXxx -fuzztime=30s
# 运行特定测试
go test -run "TestUser/创建" -v
主题测试文章,只做测试使用。发布者:Walker,转转请注明出处:https://walker-learn.xyz/archives/6723