404 Not Found

404 Not Found


nginx

测试

第23课:测试

生活类比

想象你开了一家餐厅。每道菜在端上桌之前,厨师都会先尝一口——确认咸淡适中、火候到位。软件测试就像这个"尝菜"的过程:在代码交付给用户之前,先用自动化的方式验证它是否按预期工作。如果不测试就上线,就像没尝过的菜直接端给客人,迟早会出问题。

核心概念

Go 语言内置了完整的测试工具链,无需第三方框架。核心概念如下:

概念 说明
testing.T 单元测试的上下文对象,用于报告测试失败
testing.B 基准测试的上下文对象,用于测量性能
testing.M 测试入口对象,用于 TestMain 全局设置
*_test.go 测试文件命名约定,仅在测试时编译
go test 运行测试的命令行工具
表驱动测试 用数据表驱动多组测试用例的惯用模式
httptest HTTP 请求的测试辅助包

基本语法与用法

测试文件约定

测试文件必须以 _test.go 结尾,放在与被测代码相同的包中:

myapp/
├── math.go
└── math_test.go

Test 函数签名

GO
func TestXxx(t *testing.T) {
    // 测试逻辑
}

函数名必须以 Test 开头,参数为 *testing.T

断言方式

Go 没有内置 assert 函数,需要手动判断:

GO
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 1:测试函数的参数和返回值应尽量简单,便于验证。复杂的输入用表驱动测试。

💡 Tip 2:用 t.Helper() 标记你的断言辅助函数,这样失败时会报告调用者的位置而不是辅助函数内部。

💡 Tip 3go test -v 显示详细输出,go test -run=正则 只运行匹配的测试,-count=1 禁用缓存。

运行测试

BASH
# 运行当前包的所有测试
go test

# 详细输出
go test -v

# 运行名称匹配的测试
go test -run TestAdd

# 运行当前目录及子目录
go test ./...

# 显示覆盖率
go test -cover

示例

示例:基础单元测试(难度⭐)

文件结构:

calculator/
├── calc.go
└── calc_test.go
▶ 试一试

calc.go:

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:

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("除以零应该返回错误")
    }
}

运行结果:

BASH
$ 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:

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:

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

运行某个子测试:

BASH
# 只运行中文回文用例
$ go test -v -run=TestIsPalindrome/中文回文
=== RUN   TestIsPalindrome/中文回文
--- PASS: TestIsPalindrome/中文回文 (0.00s)
PASS
💡 表驱动测试的好处:新增用例只需在切片中加一行,无需写新的测试函数。


示例:Benchmark、TestMain 与 httptest(难度⭐⭐⭐)

handler.go:

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:

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 在同包中
    }
}

运行基准测试:

BASH
$ 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

查看覆盖率:

BASH
# 生成覆盖率报告
$ 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 开发中,认证、日志等中间件需要单独测试:

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

测试:

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

GO
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 与测试:

GO
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 参数配合正则:

BASH
# 运行所有失败后重新执行的测试(手动指定名称)
go test -run TestDivideByZero -v

# 如果使用了 go test -v,失败的测试名会在输出中显示

也可以配合 -count=1 禁用缓存,确保测试确实重新运行:

BASH
go test -run TestSomething -count=1 -v

Q3:t.Fatalt.Error 有什么区别?

一般原则:如果后续断言依赖前面的结果(如解引用指针前先检查 err),用 Fatal;独立的多个断言用 Error

Q4:如何跳过耗时的测试?

GO
func TestSlowOperation(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过耗时测试(使用 -short 标志)")
    }
    // 耗时操作...
}

运行时加 -short 标志即可跳过:

BASH
go test -short

📖 小节

本课学习了 Go 语言测试的核心内容:

  1. Test 函数:以 Test 开头,参数 *testing.T,用 Error/Fatal 报告失败
  2. 表驱动测试:用切片定义用例,循环 + t.Run 运行,是 Go 社区的惯用模式
  3. 子测试t.Run(name, func) 让用例独立运行,便于定位问题
  4. 基准测试:以 Benchmark 开头,参数 *testing.B,循环 b.N 次测量性能
  5. TestMain:全局入口,用于 setup/teardown,调用 m.Run() 执行所有测试
  6. httptesthttptest.NewRequest + httptest.NewRecorder 测试 HTTP 处理器
  7. 覆盖率go test -cover 查看,-coverprofile 生成详细报告

📝 作业

练习1:编写字符串处理函数的测试

为以下函数编写测试,至少包含 5 个表驱动用例:

GO
// 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.Marshaljson.Encoder 的性能差异:

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

要求:

练习3:使用 TestMain 实现测试数据库

设计一个使用 TestMain 的测试方案:

  1. TestMain 中创建临时 SQLite 数据库
  2. 运行迁移建表
  3. 执行所有测试
  4. 测试结束后删除临时数据库

提示:使用 m.Run() 的返回值作为 os.Exit() 的参数。


下一课

下一课:正则与日期 →

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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