Go: Go 测试:testing 包与基准测试

Go 的标准库自带测试框架——无需第三方测试库,无需 assert library,用最基础的 testing 包就能写出高质量测试。

Go 的内置测试工具链在业界独树一帜:go test 自动发现测试函数,表驱动测试是 Go 社区的招牌风格,Benchmark 和覆盖率开箱即用。这节课你将掌握 Go 测试的全部核心。

1. 你将学到


2. 一个重构工程师的真实故事

(1) 痛点:改了一个函数,3 个模块同时崩溃

Alice 是支付团队的后端工程师,她被要求重构支付模块的税费计算逻辑:

"我只是改了一个字段名,以为没事。结果上线后 30% 的订单税费算错了——PM 说损失了 $5,000。因为没有测试,没人知道 ta 改了哪里。"

她打开支付模块的代码,发现整个项目零测试文件

GO
// 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 的解法:内置测试框架

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)
            }
        })
    }
}

运行测试:

BASH
$ 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
代码质量 凭感觉 数据驱动
新人上手 不敢改 改完跑测试就放心
回归成本 人工验证 自动化
💡 提示: Go 社区的最佳实践是表驱动测试——把测试用例组织为表格(slice of struct),每个 case 一个子测试。这种模式比函数级测试更清晰、更易扩展。


3. testing.T 单元测试基础

(1) 测试函数规则

GO
// 规则:
// 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 种写法

GO 📖 仅展示
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)
            }
        })
    }
}
逻辑代码 60 行(超过 40 行限制,仅展示)

4. 表驱动测试(Go 招牌风格)

(1) 标准模板

GO
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
}

▶ 示例:表驱动 + 复杂输入

GO 📖 仅展示
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)
            }
        })
    }
}
逻辑代码 41 行(超过 40 行限制,仅展示)

5. testing.B 基准测试

(1) Benchmark 基础

GO
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)
    }
}
BASH
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: example
BenchmarkAdd-8    1000000000    0.25 ns/op
PASS
ok      example 0.3s

▶ 示例:比较两种字符串拼接性能

GO
// 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)
    }
}
▶ 试一试
BASH
$ 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

GO
// 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")
}
BASH
$ 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 辅助函数

GO
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) 覆盖率基础

GO
// math.go
package main

func IsEven(n int) bool {
    return n%2 == 0
}

func IsPositive(n int) bool {
    return n > 0
}
GO
// 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)
        }
    }
}
BASH
$ 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 记录每行执行次数
🔥 易错: 覆盖率 100% ≠ 代码无 bug。覆盖率只告诉你哪些行被执行了,不告诉你逻辑是否正确。写测试的目标不是覆盖率数字,而是对关键路径的信心。


8. httptest HTTP 测试

(1) httptest.Server + httptest.ResponseRecorder

GO
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 + 表驱动

GO 📖 仅展示
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)
            }
        })
    }
}
逻辑代码 43 行(超过 40 行限制,仅展示)

(2) httptest 两种模式

模式 httptest.NewRecorder httptest.NewServer
测试对象 单个 Handler 完整 HTTP 服务
启动开销 有(监听随机端口)
适用场景 单元测试 集成测试
可以测中间件? ✅ 手动构造 ✅ 自动经过完整管线

9. 完整示例:支付模块重构测试套件

GO
// 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)
            }
        })
    }
}
100%
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.TTtesting.T


❓ 常见问题

Q testing 包有哪些核心函数?
A testing.T(单元测试)、testing.B(基准测试)、testing.M(测试入口)、testing.Helper()(辅助函数标记)、testing.Short()(跳过长测试)。
Q 表驱动测试怎么写?
A 定义一个 []struct 作为测试表格,每个 case 包含 name + 输入 + 期望输出,遍历表格用 t.Run(tt.name, ...) 执行子测试。Go 社区认为这是测试的标准写法。
Q Benchmark 怎么跑?
A go test -bench=. 运行所有 Benchmark;go test -bench=FuncName 运行指定函数;-benchmem 显示内存分配信息。b.N 由框架自动决定迭代次数。
Q 覆盖率怎么看?
A go test -cover 显示百分比;go test -coverprofile=c.out 输出文件;go tool cover -html=c.out 浏览器可视化。标准是 70%+,核心逻辑建议 90%+。
Q httptest 怎么测 HTTP handler?
A 两种方式:httptest.NewRecorder() 直接测 handler(单元测试),httptest.NewServer(handler) 启动真实 HTTP 服务测(集成测试)。推荐先用 Recorder。
Q TestMain 的 Setup/Teardown 顺序?
A TestMain 在包的所有测试函数之前执行一次。m.Run() 之前做 Setup,之后做 Teardown。注意:output 会被缓存,必须 os.Exit(code) 退出。
Q go test 缓存了什么?
A go test 默认缓存测试结果(基于代码 + 环境)。下次运行无变化时显示 (cached)。用 go test -count=1 强制重新运行。
Q 如何跳过某些测试?
A t.Skip("reason") 跳过当前测试;testing.Short() 配合 go test -short 跳过长时间测试;t.Skipf(format, args...) 格式化跳过。

📖 小节


📝 作业

  1. 基础题(难度⭐):为之前第 4 课(函数)中的 max(nums ...int) int 编写表驱动测试,覆盖:正数、负数、混合、单元素、空参数 5 个 case。

  2. 进阶题(难度⭐⭐):为第 10 课(文件 IO 与 JSON)中的 Exporter 编写测试:用 os.CreateTemp 创建临时文件测试导出;用 json.Unmarshal 验证内容正确性;用 t.Cleanup 清理临时文件。

  3. 挑战题(难度⭐⭐⭐):为第 7 课的多支付网关系统编写完整测试套件:(1) 表驱动测试 MockPaymentGateway(返回固定结果);(2) Benchmark 对比 Stripe 和 PayPal 实现的速度;(3) httptest 测试支付 Handler 的 JSON 响应格式;(4) 覆盖率 ≥ 90%。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏