Go: Go 测试:testing 包与基准测试
Go 的标准库自带测试框架——无需第三方测试库,无需 assert library,用最基础的 testing 包就能写出高质量测试。
Go 的内置测试工具链在业界独树一帜:go test 自动发现测试函数,表驱动测试是 Go 社区的招牌风格,Benchmark 和覆盖率开箱即用。这节课你将掌握 Go 测试的全部核心。
1. 你将学到
testing.T单元测试基础- 表驱动测试(Table-Driven Tests)
- 子测试(
t.Run) testing.B基准测试 BenchmarkTestMain测试入口与 setup/teardown- 覆盖率
go test -cover httptestHTTP 测试- 综合案例:支付模块测试套件
2. 一个重构工程师的真实故事
(1) 痛点:改了一个函数,3 个模块同时崩溃
Alice 是支付团队的后端工程师,她被要求重构支付模块的税费计算逻辑:
"我只是改了一个字段名,以为没事。结果上线后 30% 的订单税费算错了——PM 说损失了 $5,000。因为没有测试,没人知道 ta 改了哪里。"
她打开支付模块的代码,发现整个项目零测试文件:
// payment.go — 没有对应的 payment_test.go
func CalculateTax(amount float64, country string) float64 {
// 没人知道这个函数是否正确,因为没有测试
switch country {
case "US":
return amount * 0.08
case "CN":
return amount * 0.13
default:
return amount * 0.10
}
}
Alice 加上测试后,立刻发现了问题——她之前把 "UK" 的税率写成了 20% 而不是正确的 VAT 20%(其实是 20% 没错,但有个边界条件 amount=0 返回了 NaN)。
(2) Go 的解法:内置测试框架
// payment_test.go
package main
import "testing"
// 表驱动测试
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
country string
want float64
}{
{"US standard", 100.0, "US", 8.0},
{"CN standard", 100.0, "CN", 13.0},
{"UK default", 100.0, "UK", 10.0},
{"zero amount", 0.0, "US", 0.0},
{"negative amount", -50.0, "US", -4.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateTax(tt.amount, tt.country)
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
运行测试:
$ go test -v
=== RUN TestCalculateTax/US_standard
=== RUN TestCalculateTax/CN_standard
=== RUN TestCalculateTax/UK_default
=== RUN TestCalculateTax/zero_amount
=== RUN TestCalculateTax/negative_amount
--- PASS: TestCalculateTax (0.00s)
--- PASS: TestCalculateTax/US_standard (0.00s)
--- PASS: TestCalculateTax/CN_standard (0.00s)
--- PASS: TestCalculateTax/UK_default (0.00s)
--- PASS: TestCalculateTax/zero_amount (0.00s)
--- PASS: TestCalculateTax/negative_amount (0.00s)
PASS
ok payment 0.123s
(3) 收益:有测试 vs 无测试
| 维度 | 无测试 | 有测试 |
|---|---|---|
| 重构信心 | 改一行都怕 | 改了就跑 go test |
| 定位问题 | 上线后用户报错 | 开发阶段 fail |
| 代码质量 | 凭感觉 | 数据驱动 |
| 新人上手 | 不敢改 | 改完跑测试就放心 |
| 回归成本 | 人工验证 | 自动化 |
3. testing.T 单元测试基础
(1) 测试函数规则
// 规则:
// 1. 文件名必须以 _test.go 结尾
// 2. 函数签名必须是 func TestXxx(t *testing.T)
// 3. Xxx 大写字母开头
// math_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2,3) = %d, want %d", got, want)
}
}
func TestSubtract(t *testing.T) {
got := Subtract(5, 3)
want := 2
if got != want {
t.Errorf("Subtract(5,3) = %d, want %d", got, want)
}
}
(2) testing.T 常用方法
| 方法 | 行为 | 继续执行? |
|---|---|---|
t.Log(args...) |
打印日志(仅 -v 时显示) | ✅ |
t.Error(args...) |
标记失败 + 继续执行 | ✅ |
t.Errorf(format, args...) |
格式化 Error | ✅ |
t.Fatal(args...) |
标记失败 + 停止当前测试 | ❌ |
t.Fatalf(format, args...) |
格式化 Fatal | ❌ |
t.Skip(args...) |
跳过本测试 | ✅ |
▶ 示例:测试函数 4 种写法
package main
import (
"fmt"
"testing"
)
// 被测函数
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
var ErrDivisionByZero = fmt.Errorf("division by zero")
// 写法 1:简单断言
func TestDivideBasic(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5.0 {
t.Errorf("got %f, want %f", result, 5.0)
}
}
// 写法 2:Error vs Fatal
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "division by zero" {
t.Errorf("wrong error message: %v", err)
}
}
// 写法 3:表驱动测试
func TestDivideTable(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"10/2", 10, 2, 5, false},
{"0/5", 0, 5, 0, false},
{"-6/3", -6, 3, -2, false},
{"1/0", 1, 0, 0, true},
}
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.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %f, want %f", got, tt.want)
}
})
}
}
4. 表驱动测试(Go 招牌风格)
(1) 标准模板
package main
import "testing"
func TestMax(t *testing.T) {
// 1. 定义测试表格
tests := []struct {
name string // 测试名称(用于子测试)
a, b int // 输入
want int // 期望输出
}{
{name: "first larger", a: 10, b: 3, want: 10},
{name: "second larger", a: 3, b: 10, want: 10},
{name: "equal", a: 5, b: 5, want: 5},
{name: "negative", a: -3, b: -10, want: -3},
{name: "zero", a: 0, b: 5, want: 5},
}
// 2. 遍历表格
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 3. 执行 + 断言
got := Max(tt.a, tt.b)
if got != tt.want {
t.Errorf("Max(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
▶ 示例:表驱动 + 复杂输入
package main
import (
"testing"
)
// 被测函数:验证密码强度
type PasswordStrength int
const (
Weak PasswordStrength = iota
Medium
Strong
)
func CheckPassword(pwd string) PasswordStrength {
if len(pwd) < 6 {
return Weak
}
if len(pwd) >= 12 {
return Strong
}
return Medium
}
func TestCheckPassword(t *testing.T) {
tests := []struct {
name string
pwd string
want PasswordStrength
}{
{"short", "abc", Weak},
{"medium 6", "abcdef", Medium},
{"medium 8", "abcdefgh", Medium},
{"strong 12", "abcdefghijkl", Strong},
{"empty", "", Weak},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CheckPassword(tt.pwd)
if got != tt.want {
t.Errorf("CheckPassword(%q) = %v, want %v",
tt.pwd, got, tt.want)
}
})
}
}
5. testing.B 基准测试
(1) Benchmark 基础
package main
import (
"testing"
)
// 基准函数:func BenchmarkXxx(b *testing.B)
func BenchmarkAdd(b *testing.B) {
a, c := 100, 200
for i := 0; i < b.N; i++ {
Add(a, c)
}
}
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: example
BenchmarkAdd-8 1000000000 0.25 ns/op
PASS
ok example 0.3s
▶ 示例:比较两种字符串拼接性能
// concat_test.go
package main
import (
"strings"
"testing"
)
func ConcatPlus(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "a"
}
return s
}
func ConcatBuilder(n int) string {
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
sb.WriteByte('a')
}
return sb.String()
}
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(1000)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(1000)
}
}
$ go test -bench=. -benchmem
BenchmarkConcatPlus-8 13134 91238 ns/op 530296 allocs/op
BenchmarkConcatBuilder-8 283321 4221 ns/op 56 allocs/op
+ 拼接 1000 次比 Builder 慢 20 倍,内存分配多 10,000 倍——-benchmem 让你看到内存分配的差异。
(2) Benchmark 输出解读
| 输出项 | 含义 |
|---|---|
BenchmarkConcatBuilder-8 |
测试名-8(8 个 CPU) |
283321 |
b.N = 283321 次迭代 |
4221 ns/op |
每次操作 4221 纳秒 |
56 allocs/op |
每次操作 56 次内存分配 |
6. TestMain 测试入口
(1) TestMain:setup / teardown
// main_test.go
package main
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// Setup(整个包执行一次)
fmt.Println("=== Setup: 初始化数据库连接 ===")
// 运行所有测试
code := m.Run()
// Teardown
fmt.Println("=== Teardown: 关闭数据库连接 ===")
os.Exit(code)
}
func TestSomething(t *testing.T) {
t.Log("测试 A")
}
func TestAnother(t *testing.T) {
t.Log("测试 B")
}
$ go test -v
=== Setup: 初始化数据库连接 ===
=== RUN TestSomething
main_test.go:16: 测试 A
--- PASS: TestSomething (0.00s)
=== RUN TestAnother
main_test.go:20: 测试 B
--- PASS: TestAnother (0.00s)
=== Teardown: 关闭数据库连接 ===
PASS
ok example 0.1s
▶ 示例:testing.Helper 辅助函数
package main
import "testing"
func Add(a, b int) int { return a + b }
func assertEqual(t testing.TB, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestWithHelper(t *testing.T) {
assertEqual(t, Add(2, 3), 5)
assertEqual(t, Add(0, 0), 0)
assertEqual(t, Add(-1, 1), 0)
}
t.Helper() 让报错信息定位到调用者行号,而不是辅助函数内部。这是编写测试工具函数的关键实践。
7. 覆盖率
(1) 覆盖率基础
// math.go
package main
func IsEven(n int) bool {
return n%2 == 0
}
func IsPositive(n int) bool {
return n > 0
}
// math_test.go
package main
import "testing"
func TestIsEven(t *testing.T) {
tests := []struct {
n int
want bool
}{
{2, true},
{3, false},
}
for _, tt := range tests {
if got := IsEven(tt.n); got != tt.want {
t.Errorf("IsEven(%d) = %v", tt.n, got)
}
}
}
$ go test -coverprofile=coverage.out
ok example 0.1s coverage: 50.0% of statements
$ go tool cover -html=coverage.out # 浏览器打开覆盖率报告
(2) go test -cover 常用命令
| 命令 | 作用 |
|---|---|
go test -cover |
终端显示覆盖率 |
go test -coverprofile=c.out |
输出覆盖率文件 |
go tool cover -html=c.out |
浏览器查看可视化报告 |
go test -covermode=count |
记录每行执行次数 |
8. httptest HTTP 测试
(1) httptest.Server + httptest.ResponseRecorder
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// 被测 handler
type UserHandler struct{}
func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{"status": "ok", "message": "hello"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// 测试 1:httptest.ResponseRecorder(测试 handler 本身)
func TestUserHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler := UserHandler{}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("got status %q, want %q", resp["status"], "ok")
}
}
// 测试 2:httptest.Server(测试完整 HTTP 服务)
func TestUserHandlerWithServer(t *testing.T) {
server := httptest.NewServer(UserHandler{})
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got %d, want %d", resp.StatusCode, http.StatusOK)
}
}
▶ 示例:httptest + 表驱动
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// 被测 handler
func greetingHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
resp := map[string]string{"message": "Hello, " + name + "!"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func TestGreetingHandler(t *testing.T) {
tests := []struct {
name string
query string
want string
status int
}{
{"with name", "name=Alice", "Hello, Alice!", 200},
{"empty name", "", "Hello, World!", 200},
{"with special chars", "name=Go+Lang", "Hello, Go Lang!", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/?"+tt.query, nil)
rec := httptest.NewRecorder()
greetingHandler(rec, req)
if rec.Code != tt.status {
t.Errorf("status = %d, want %d", rec.Code, tt.status)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["message"] != tt.want {
t.Errorf("message = %q, want %q", resp["message"], tt.want)
}
})
}
}
(2) httptest 两种模式
| 模式 | httptest.NewRecorder | httptest.NewServer |
|---|---|---|
| 测试对象 | 单个 Handler | 完整 HTTP 服务 |
| 启动开销 | 无 | 有(监听随机端口) |
| 适用场景 | 单元测试 | 集成测试 |
| 可以测中间件? | ✅ 手动构造 | ✅ 自动经过完整管线 |
9. 完整示例:支付模块重构测试套件
// payment_test.go
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// ---------- 被测代码 ----------
type PaymentRequest struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Country string `json:"country"`
}
type PaymentResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Tax float64 `json:"tax,omitempty"`
Total float64 `json:"total,omitempty"`
}
func CalculateTax(amount float64, country string) (float64, error) {
if amount < 0 {
return 0, errors.New("negative amount")
}
switch country {
case "US":
return amount * 0.08, nil
case "CN":
return amount * 0.13, nil
case "DE":
return amount * 0.19, nil
default:
return amount * 0.10, nil
}
}
func paymentHandler(w http.ResponseWriter, r *http.Request) {
var req PaymentRequest
json.NewDecoder(r.Body).Decode(&req)
tax, err := CalculateTax(req.Amount, req.Country)
if err != nil {
json.NewEncoder(w).Encode(PaymentResponse{
Success: false,
Message: err.Error(),
})
return
}
json.NewEncoder(w).Encode(PaymentResponse{
Success: true,
Tax: tax,
Total: req.Amount + tax,
})
}
// ---------- 测试代码 ----------
func jsonBody(s string) *bytes.Reader {
return bytes.NewReader([]byte(s))
}
// 1. 单元测试:CalculateTax 表驱动
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
country string
want float64
wantErr bool
}{
{"US $100", 100, "US", 8.0, false},
{"CN ¥100", 100, "CN", 13.0, false},
{"DE €100", 100, "DE", 19.0, false},
{"UK default", 100, "UK", 10.0, false},
{"zero amount", 0, "US", 0, false},
{"negative amount", -100, "US", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateTax(tt.amount, tt.country)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
// 2. Benchmark:不同方式计算税费
func BenchmarkCalculateTax(b *testing.B) {
for i := 0; i < b.N; i++ {
CalculateTax(100.0, "US")
}
}
// 3. HTTP 测试:paymentHandler
func TestPaymentHandler(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantSuccess bool
}{
{"US payment", `{"user_id":"u1","amount":100,"country":"US"}`, 200, true},
{"CN payment", `{"user_id":"u2","amount":200,"country":"CN"}`, 200, true},
{"negative amount", `{"user_id":"u3","amount":-50,"country":"US"}`, 200, false},
{"invalid JSON", `not json`, 200, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/pay",
jsonBody(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
paymentHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp PaymentResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Success != tt.wantSuccess {
t.Errorf("success = %v, want %v", resp.Success, tt.wantSuccess)
}
})
}
}
sequenceDiagram
participant Dev
participant Terminal as go test
participant Package as 被测包
participant Coverage as coverage.out
Dev->>Terminal: go test -v -cover
Terminal->>Package: 1. 查找 *_test.go
Terminal->>Package: 2. 调用 TestMain(m)
Package->>Package: 3. Setup
Package->>Package: 4. 执行所有 TestXxx
Package->>Package: 5. 执行 BenchmarkXxx
Package->>Package: 6. Teardown
Package-->>Terminal: PASS / FAIL
Terminal-->>Coverage: 覆盖率数据
Terminal-->>Dev: 结果汇总
_test.go 后缀,否则 go test 不会执行。函数签名必须严格 func TestXxx(t *testing.T)——参数是 *testing.T,不是 *testing.TT 或 testing.T。
❓ 常见问题
testing.T(单元测试)、testing.B(基准测试)、testing.M(测试入口)、testing.Helper()(辅助函数标记)、testing.Short()(跳过长测试)。[]struct 作为测试表格,每个 case 包含 name + 输入 + 期望输出,遍历表格用 t.Run(tt.name, ...) 执行子测试。Go 社区认为这是测试的标准写法。go test -bench=. 运行所有 Benchmark;go test -bench=FuncName 运行指定函数;-benchmem 显示内存分配信息。b.N 由框架自动决定迭代次数。go test -cover 显示百分比;go test -coverprofile=c.out 输出文件;go tool cover -html=c.out 浏览器可视化。标准是 70%+,核心逻辑建议 90%+。httptest.NewRecorder() 直接测 handler(单元测试),httptest.NewServer(handler) 启动真实 HTTP 服务测(集成测试)。推荐先用 Recorder。m.Run() 之前做 Setup,之后做 Teardown。注意:output 会被缓存,必须 os.Exit(code) 退出。go test 默认缓存测试结果(基于代码 + 环境)。下次运行无变化时显示 (cached)。用 go test -count=1 强制重新运行。t.Skip("reason") 跳过当前测试;testing.Short() 配合 go test -short 跳过长时间测试;t.Skipf(format, args...) 格式化跳过。📖 小节
- Go 内置 testing 包,无需第三方测试框架
- 表驱动测试是 Go 社区招牌风格:
[]struct+t.Run - 子测试
t.Run让测试结果层级清晰 testing.B基准测试:b.N自动调节,-benchmem看内存TestMain(m)提供包级 setup/teardowngo test -cover衡量行覆盖率httptest.NewRecorder和httptest.NewServer测试 HTTP- 测试文件命名
_test.go,函数签名func TestXxx(t *testing.T)
📝 作业
-
基础题(难度⭐):为之前第 4 课(函数)中的
max(nums ...int) int编写表驱动测试,覆盖:正数、负数、混合、单元素、空参数 5 个 case。 -
进阶题(难度⭐⭐):为第 10 课(文件 IO 与 JSON)中的
Exporter编写测试:用os.CreateTemp创建临时文件测试导出;用json.Unmarshal验证内容正确性;用t.Cleanup清理临时文件。 -
挑战题(难度⭐⭐⭐):为第 7 课的多支付网关系统编写完整测试套件:(1) 表驱动测试 MockPaymentGateway(返回固定结果);(2) Benchmark 对比 Stripe 和 PayPal 实现的速度;(3) httptest 测试支付 Handler 的 JSON 响应格式;(4) 覆盖率 ≥ 90%。