测试
第23课:测试
生活类比
想象你开了一家餐厅。每道菜在端上桌之前,厨师都会先尝一口——确认咸淡适中、火候到位。软件测试就像这个"尝菜"的过程:在代码交付给用户之前,先用自动化的方式验证它是否按预期工作。如果不测试就上线,就像没尝过的菜直接端给客人,迟早会出问题。
核心概念
Go 语言内置了完整的测试工具链,无需第三方框架。核心概念如下:
| 概念 | 说明 |
|---|---|
testing.T |
单元测试的上下文对象,用于报告测试失败 |
testing.B |
基准测试的上下文对象,用于测量性能 |
testing.M |
测试入口对象,用于 TestMain 全局设置 |
*_test.go |
测试文件命名约定,仅在测试时编译 |
go test |
运行测试的命令行工具 |
| 表驱动测试 | 用数据表驱动多组测试用例的惯用模式 |
httptest |
HTTP 请求的测试辅助包 |
基本语法与用法
测试文件约定
测试文件必须以 _test.go 结尾,放在与被测代码相同的包中:
myapp/
├── math.go
└── math_test.go
Test 函数签名
func TestXxx(t *testing.T) {
// 测试逻辑
}
函数名必须以 Test 开头,参数为 *testing.T。
断言方式
Go 没有内置 assert 函数,需要手动判断:
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, 期望 5", result)
}
}
常用方法:
| 方法 | 作用 |
|---|---|
t.Error() / t.Errorf() |
报告错误,继续执行 |
t.Fatal() / t.Fatalf() |
报告错误,立即终止当前测试 |
t.Skip() / t.Skipf() |
跳过当前测试 |
t.Log() / t.Logf() |
输出日志(仅 -v 时显示) |
t.Run(name, func) |
运行子测试 |
t.Helper() |
标记为辅助函数,错误报告指向调用方 |
💡 Tip 2:用 t.Helper() 标记你的断言辅助函数,这样失败时会报告调用者的位置而不是辅助函数内部。
💡 Tip 3:go test -v 显示详细输出,go test -run=正则 只运行匹配的测试,-count=1 禁用缓存。
运行测试
# 运行当前包的所有测试
go test
# 详细输出
go test -v
# 运行名称匹配的测试
go test -run TestAdd
# 运行当前目录及子目录
go test ./...
# 显示覆盖率
go test -cover
示例
示例:基础单元测试(难度⭐)
文件结构:
calculator/
├── calc.go
└── calc_test.go
calc.go:
package calculator
// Add 返回两数之和
func Add(a, b int) int {
return a + b
}
// Divide 返回商和是否有错误
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为零")
}
return a / b, nil
}
calc_test.go:
package calculator
import (
"testing"
)
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, 期望 %d", got, want)
}
}
func TestDivide(t *testing.T) {
got, err := Divide(10, 3)
if err != nil {
t.Fatalf("意外错误: %v", err)
}
want := 3.3333333333333335
if got != want {
t.Errorf("Divide(10, 3) = %f, 期望 %f", got, want)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("除以零应该返回错误")
}
}
运行结果:
$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDivide
--- PASS: TestDivide (0.00s)
=== RUN TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok calculator 0.003s
示例:表驱动测试与子测试(难度⭐⭐)
表驱动测试是 Go 社区最推崇的测试模式——把输入和期望输出放在一个切片里,循环验证。
stringutil.go:
package stringutil
import "unicode"
// 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)
}
// IsPalindrome 判断是否回文
func IsPalindrome(s string) bool {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
return false
}
}
return true
}
stringutil_test.go:
package stringutil
import "testing"
func TestReverse(t *testing.T) {
// 定义测试用例表
tests := []struct {
name string // 用例名称
input string
want string
}{
{"空字符串", "", ""},
{"单字符", "a", "a"},
{"普通字符串", "hello", "olleh"},
{"中文", "你好世界", "界世好你"},
{"回文", "racecar", "racecar"},
}
for _, tt := range tests {
// t.Run 创建子测试,每个用例独立运行
t.Run(tt.name, func(t *testing.T) {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q, 期望 %q", tt.input, got, tt.want)
}
})
}
}
func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"英文回文", "racecar", true},
{"中文回文", "上海自来水来自海上", true},
{"非回文", "hello", false},
{"忽略大小写", "RaceCar", true},
{"空字符串", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPalindrome(tt.input)
if got != tt.want {
t.Errorf("IsPalindrome(%q) = %v, 期望 %v", tt.input, got, tt.want)
}
})
}
}
运行某个子测试:
# 只运行中文回文用例
$ go test -v -run=TestIsPalindrome/中文回文
=== RUN TestIsPalindrome/中文回文
--- PASS: TestIsPalindrome/中文回文 (0.00s)
PASS
示例:Benchmark、TestMain 与 httptest(难度⭐⭐⭐)
handler.go:
package handler
import (
"encoding/json"
"net/http"
)
type Response struct {
Message string `json:"message"`
Code int `json:"code"`
}
// HelloHandler 处理 /hello 请求
func HelloHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "世界"
}
resp := Response{
Message: "你好, " + name,
Code: 200,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
handler_test.go:
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestMain 在所有测试之前执行,可用于全局设置和清理
func TestMain(m *testing.M) {
// 此处可进行数据库连接、初始化配置等
setup()
// 运行所有测试
code := m.Run()
// 清理资源
teardown()
// 使用测试结果作为退出码
os.Exit(code)
}
func setup() {
// 初始化代码(如加载测试配置)
}
func teardown() {
// 清理代码(如关闭数据库连接)
}
func TestHelloHandler(t *testing.T) {
tests := []struct {
name string
queryParam string
wantMsg string
wantCode int
}{
{"默认名称", "", "你好, 世界", 200},
{"自定义名称", "Go", "你好, Go", 200},
{"中文名称", "小明", "你好, 小明", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 构造请求
url := "/hello"
if tt.queryParam != "" {
url += "?name=" + tt.queryParam
}
req := httptest.NewRequest(http.MethodGet, url, nil)
// 创建响应记录器
rr := httptest.NewRecorder()
// 调用处理器
HelloHandler(rr, req)
// 验证状态码
if rr.Code != tt.wantCode {
t.Errorf("状态码 = %d, 期望 %d", rr.Code, tt.wantCode)
}
// 验证响应体
var resp Response
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if resp.Message != tt.wantMsg {
t.Errorf("Message = %q, 期望 %q", resp.Message, tt.wantMsg)
}
})
}
}
// BenchmarkHelloHandler 基准测试,测量处理器性能
func BenchmarkHelloHandler(b *testing.B) {
req := httptest.NewRequest(http.MethodGet, "/hello?name=Go", nil)
// b.N 由测试框架自动调整,确保结果稳定
for i := 0; i < b.N; i++ {
rr := httptest.NewRecorder()
HelloHandler(rr, req)
}
}
// BenchmarkReverse 对字符串反转进行基准测试
func BenchmarkReverse(b *testing.B) {
s := "Hello, 世界!这是一个测试字符串"
for i := 0; i < b.N; i++ {
_ = Reverse(s) // 假设 Reverse 在同包中
}
}
运行基准测试:
$ go test -bench=. -benchmem -count=3
goos: linux
goarch: amd64
pkg: handler
BenchmarkHelloHandler-8 200000 7523 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7401 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7350 ns/op 1248 B/op 18 allocs/op
PASS
ok handler 4.832s
查看覆盖率:
# 生成覆盖率报告
$ go test -coverprofile=coverage.out
# 在终端查看每个函数的覆盖率
$ go tool cover -func=coverage.out
total: (statements) 85.7%
# 生成 HTML 报告(浏览器打开)
$ go tool cover -html=coverage.out -o coverage.html
场景应用
场景1:测试中间件(Middleware)
在 Web 开发中,认证、日志等中间件需要单独测试:
package middleware
import (
"net/http"
"strings"
)
// AuthMiddleware 检查请求头中的 Token
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer ") {
http.Error(w, "未授权", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
测试:
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware(t *testing.T) {
// 创建一个简单的下游处理器
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("通过"))
})
middleware := AuthMiddleware(nextHandler)
t.Run("无Token应返回401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("状态码 = %d, 期望 %d", rr.Code, http.StatusUnauthorized)
}
})
t.Run("有效Token应通过", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("状态码 = %d, 期望 %d", rr.Code, http.StatusOK)
}
})
t.Run("无效前缀应返回401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Basic abc123")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("状态码 = %d, 期望 %d", rr.Code, http.StatusUnauthorized)
}
})
}
场景2:测试数据库操作(接口隔离)
通过接口隔离数据库依赖,测试时注入 mock:
package user
import "context"
// UserRepository 定义用户数据访问接口
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
// User 用户模型
type User struct {
ID int
Name string
}
// Service 用户业务逻辑
type Service struct {
repo UserRepository
}
// NewService 创建用户服务
func NewService(repo UserRepository) *Service {
return &Service{repo: repo}
}
// GetUser 获取用户,找不到时返回错误
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("无效的用户ID: %d", id)
}
return s.repo.GetByID(ctx, id)
}
Mock 与测试:
package user
import (
"context"
"testing"
)
// mockRepo 实现 UserRepository 接口,用于测试
type mockRepo struct {
users map[int]*User
}
func (m *mockRepo) GetByID(_ context.Context, id int) (*User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, fmt.Errorf("用户 %d 不存在", id)
}
func (m *mockRepo) Create(_ context.Context, user *User) error {
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
mock := &mockRepo{
users: map[int]*User{
1: {ID: 1, Name: "张三"},
2: {ID: 2, Name: "李四"},
},
}
svc := NewService(mock)
t.Run("存在的用户", func(t *testing.T) {
user, err := svc.GetUser(context.Background(), 1)
if err != nil {
t.Fatalf("意外错误: %v", err)
}
if user.Name != "张三" {
t.Errorf("Name = %q, 期望 %q", user.Name, "张三")
}
})
t.Run("不存在的用户", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), 99)
if err == nil {
t.Error("期望返回错误")
}
})
t.Run("无效ID", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), -1)
if err == nil {
t.Error("期望返回错误")
}
})
}
❓ 常见问题
Q1:测试文件不被识别怎么办?
确保文件名以 _test.go 结尾,且包名正确。测试文件在 go build 时会被忽略,仅在 go test 时编译。如果测试文件在 _test 包中(即 package foo_test),则只能测试导出的标识符。
Q2:如何只运行失败的测试?
使用 -run 参数配合正则:
# 运行所有失败后重新执行的测试(手动指定名称)
go test -run TestDivideByZero -v
# 如果使用了 go test -v,失败的测试名会在输出中显示
也可以配合 -count=1 禁用缓存,确保测试确实重新运行:
go test -run TestSomething -count=1 -v
Q3:t.Fatal 和 t.Error 有什么区别?
t.Error()/t.Errorf():报告错误,继续执行当前测试函数中剩余的代码。t.Fatal()/t.Fatalf():报告错误,立即终止当前测试函数。
一般原则:如果后续断言依赖前面的结果(如解引用指针前先检查 err),用 Fatal;独立的多个断言用 Error。
Q4:如何跳过耗时的测试?
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("跳过耗时测试(使用 -short 标志)")
}
// 耗时操作...
}
运行时加 -short 标志即可跳过:
go test -short
📖 小节
本课学习了 Go 语言测试的核心内容:
- Test 函数:以
Test开头,参数*testing.T,用Error/Fatal报告失败 - 表驱动测试:用切片定义用例,循环 +
t.Run运行,是 Go 社区的惯用模式 - 子测试:
t.Run(name, func)让用例独立运行,便于定位问题 - 基准测试:以
Benchmark开头,参数*testing.B,循环b.N次测量性能 - TestMain:全局入口,用于 setup/teardown,调用
m.Run()执行所有测试 - httptest:
httptest.NewRequest+httptest.NewRecorder测试 HTTP 处理器 - 覆盖率:
go test -cover查看,-coverprofile生成详细报告
📝 作业
练习1:编写字符串处理函数的测试
为以下函数编写测试,至少包含 5 个表驱动用例:
// Truncate 截断字符串到指定长度,超出部分用 "..." 替代
func Truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen-3]) + "..."
}
练习2:编写 HTTP 处理器的基准测试
为一个返回 JSON 数组的 API 编写基准测试,比较 json.Marshal 和 json.Encoder 的性能差异:
// ListHandler 返回用户列表
func ListHandler(w http.ResponseWriter, r *http.Request) {
users := []User{{1, "张三"}, {2, "李四"}, {3, "王五"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
要求:
- 编写两个 Benchmark,分别用
json.Marshal和json.NewEncoder实现 - 使用
-benchmem比较内存分配
练习3:使用 TestMain 实现测试数据库
设计一个使用 TestMain 的测试方案:
- 在
TestMain中创建临时 SQLite 数据库 - 运行迁移建表
- 执行所有测试
- 测试结束后删除临时数据库
提示:使用 m.Run() 的返回值作为 os.Exit() 的参数。



