V
Vel·ToolKit
简洁 · 高效 · 即开即用
ZH
第 20 章 / 共 20 章

测试与工程实践

testing 包、表驱动、基准、httptest、Fuzz、工具链

单元测试

测试文件以 _test.go 结尾,跟被测代码同目录;函数签名 func TestXxx(t *testing.T),名字必须以 Test 开头且后接大写字母。go 命令会自动找到它们。

// add.go
package math

func Add(a, b int) int { return a + b }
// add_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    if got != 5 {
        t.Errorf("Add(2,3) = %d, want 5", got)
    }
}
$ go test ./...
$ go test -run TestAdd -v
$ go test -count=1 ./...   # 关闭测试结果缓存

表驱动测试 + 子测试

Go 最常见的测试模式:把用例放进切片,用 t.Run 让每个用例成为独立子测试。失败时能看到具体哪个子用例挂了,也支持 -run 单独跑一条。

// add_test.go
package math

import "testing"

func TestAdd_Table(t *testing.T) {
    tests := []struct {
        name       string
        a, b, want int
    }{
        {"both zero", 0, 0, 0},
        {"positive", 1, 2, 3},
        {"negative", -1, -2, -3},
    }
    for _, tc := range tests {
        tc := tc // Go < 1.22 必须重新绑定
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel() // 子测试并行
            if got := Add(tc.a, tc.b); got != tc.want {
                t.Errorf("got %d, want %d", got, tc.want)
            }
        })
    }
}

测试辅助:Helper / Cleanup / TempDir

testing.T 提供一组用于隐藏底层细节、自动清理资源、避免污染的工具。

// db_test.go
package storage

import (
    "path/filepath"
    "testing"
)

type DB struct{ path string }

func Open(path string) (*DB, error) { return &DB{path: path}, nil }
func (d *DB) Close() error           { return nil }

func mustOpenDB(t *testing.T) *DB {
    t.Helper()         // 失败时定位到调用方而不是这一行
    dir := t.TempDir() // 测试结束自动删
    db, err := Open(filepath.Join(dir, "a.db"))
    if err != nil {
        t.Fatalf("open: %v", err)
    }
    t.Cleanup(func() { db.Close() }) // 比 defer 更可靠
    return db
}

func TestDBOpen(t *testing.T) {
    db := mustOpenDB(t)
    _ = db
}

并行测试与 -race

t.Parallel() 让用例并发执行——CPU 多就能更快;同时建议加 -race 让 race detector 把并发 bug 提前暴露。

$ go test -race -parallel=8 ./...

testdata 目录

go 工具链约定:名为 testdata 的目录会被忽略(不会触发编译、不会被 vet)。把测试用的 JSON / 黄金文件放进去最合适。

// render_test.go
package render

import (
    "bytes"
    "flag"
    "os"
    "testing"
)

var update = flag.Bool("update", false, "rewrite golden files")

func Render(in []byte) []byte { return bytes.ToUpper(in) }

func TestRender(t *testing.T) {
    in, err := os.ReadFile("testdata/input.md")
    if err != nil {
        t.Fatal(err)
    }
    want, _ := os.ReadFile("testdata/expected.html")

    got := Render(in)
    if !bytes.Equal(got, want) {
        if *update { // 用 -update 重写黄金文件
            os.WriteFile("testdata/expected.html", got, 0o644)
            return
        }
        t.Errorf("got:\n%s\nwant:\n%s", got, want)
    }
}

httptest:测试 HTTP handler

net/http/httptest 提供假的 Server 和 ResponseRecorder:服务端、客户端测试都不需要真起端口。

// api_test.go
package api

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
)

type User struct{ ID int }

func UserHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "user:", r.PathValue("id"))
}

func Fetch(url string) (User, error) {
    resp, err := http.Get(url)
    if err != nil {
        return User{}, err
    }
    defer resp.Body.Close()
    var u User
    err = json.NewDecoder(resp.Body).Decode(&u)
    return u, err
}

// 测 handler
func TestUserHandler(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", UserHandler)

    req := httptest.NewRequest("GET", "/users/42", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)

    if rec.Code != 200 {
        t.Errorf("status = %d", rec.Code)
    }
}

// 测客户端代码(要打到的 server 是假的)
func TestFetch(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, `{"ID":1}`)
    }))
    defer srv.Close()

    got, err := Fetch(srv.URL)
    if err != nil || got.ID != 1 {
        t.Fatalf("Fetch = %+v, %v", got, err)
    }
}

Example 测试:双重作用

ExampleXxx 函数既是文档(在 pkg.go.dev 自动展示),又是测试——go test 会比较 // Output: 注释和实际输出。

// example_test.go
package math

import "fmt"

func ExampleAdd() {
    fmt.Println(Add(1, 2))
    // Output: 3
}

基准测试

BenchmarkXxx 函数接收 *testing.B;循环 b.N 次,Go 自动调整 N 直到测量稳定。-benchmem 输出每次操作的内存分配次数和字节数。

// bench_test.go
package math

import (
    "strings"
    "testing"
)

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = Add(1, 2)
    }
}

func BenchmarkJoin(b *testing.B) {
    parts := []string{"a", "b", "c"}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = strings.Join(parts, "-")
    }
}
$ go test -bench=. -benchmem -benchtime=2s
$ go test -bench=Add -cpuprofile=cpu.out
$ go tool pprof cpu.out          # 进交互式分析

Fuzz 测试(Go 1.18+)

FuzzXxx 函数接收 *testing.F,先用 Add 注册种子,再用 Fuzz(func(t, ...))) 让 Go 自动变异输入找崩溃点和非预期 panic。特别适合解析器、协议层。

// fuzz_test.go
package math

import (
    "strconv"
    "testing"
)

func FuzzParseInt(f *testing.F) {
    f.Add("42")
    f.Add("-1")
    f.Add("")
    f.Fuzz(func(t *testing.T, s string) {
        v, err := strconv.Atoi(s)
        if err == nil {
            if got := strconv.Itoa(v); got != s {
                t.Errorf("round trip: %q -> %d -> %q", s, v, got)
            }
        }
    })
}
$ go test -fuzz=FuzzParseInt -fuzztime=30s

覆盖率

$ go test -cover ./...
$ go test -coverprofile=cov.out && go tool cover -html=cov.out
$ go test -coverpkg=./... ./...      # 含上跨包调用

Mock 与依赖注入

Go 推荐通过接口注入依赖,测试时传一个简单的 stub struct,不需要 mock 框架。如果接口方法多,可以用 gomock / mockery 生成。

// notify_test.go
package app

import "testing"

type User struct{ Email string }

type Mailer interface {
    Send(to, body string) error
}

type service struct{ mail Mailer }

func (s *service) Notify(u User) error {
    return s.mail.Send(u.Email, "hello")
}

// 测试用 stub
type fakeMail struct{ sent []string }

func (f *fakeMail) Send(to, body string) error {
    f.sent = append(f.sent, to)
    return nil
}

func TestNotify(t *testing.T) {
    m := &fakeMail{}
    s := &service{mail: m}
    s.Notify(User{Email: "a@b.c"})
    if len(m.sent) != 1 {
        t.Fatal("not sent")
    }
}

工程实践工具链

  • gofmt / goimports:唯一的格式化标准,编辑器保存自动跑
  • go vet:内建静态检查
  • staticcheck / golangci-lint:聚合多个 linter,CI 标配
  • go build -ldflags "-s -w -X main.version=$(git describe)":瘦身 + 注入版本
  • go install 把命令装到 $GOBIN;go run 直接跑 main 包
  • 交叉编译:GOOS=linux GOARCH=amd64 go build -o app
  • go generate:触发代码生成(stringer / mockgen / sqlc 等)
  • go tool pprof / trace:性能剖析与调度可视化