8、单元测试

单元测试

Go测试的基本概念

Go语言自带了 testing 测试包,可以进行自动化的单元测试,输出结果验证,并且可以测试性能。

什么是单元测试

单元测试是针对程序中最小可测试单元(通常是函数或方法)的测试。在Go中,这通常指对特定函数或方法的输入和输出进行验证,确保它们按预期工作。单元测试的目标是:

Go测试文件命名约定

Go的测试文件遵循以下命名约定:

例如,如果你有一个名为calculator.go的文件,对应的测试文件应命名为calculator_test.go

测试函数结构

func TestXxx(t *testing.T) {
    // 测试代码
}

进行基本测试

代码编写

// calculator.go
package calculator

// Add 返回两个整数的和
func Add(a, b int) int {
    return a + b
}

// Subtract 返回两个整数的差
func Subtract(a, b int) int {
    return a - b
}

// Multiply 返回两个整数的乘积
func Multiply(a, b int) int {
    return a * b
}

// Divide 返回两个整数的商,如果除数为0则panic
func Divide(a, b int) int {
    if b == 0 {
        panic("除数不能为0")
    }
    return a / b
}

现在,为这些函数编写测试:

// calculator_test.go
package calculator

import (
	"testing"
)

func TestAdd(t *testing.T) {
	result := Add(3, 2)
	expected := 5

	if result != expected {
		t.Errorf("Add(3, 2) = %d; 期望 %d", result, expected)
	}
}

func TestSubtract(t *testing.T) {
	result := Subtract(5, 2)
	expected := 3

	if result != expected {
		t.Errorf("Subtract(5, 2) = %d; 期望 %d", result, expected)
	}
}

func TestMultiply(t *testing.T) {
	result := Multiply(4, 3)
	expected := 12

	if result != expected {
		t.Errorf("Multiply(4, 3) = %d; 期望 %d", result, expected)
	}
}

func TestDivide(t *testing.T) {
	result := Divide(6, 2)
	expected := 3

	if result != expected {
		t.Errorf("Divide(6, 2) = %d; 期望 %d", result, expected)
	}
}

func TestDivideByZero(t *testing.T) {
	// 使用匿名函数封装可能panic的操作
	defer func() {
		if r := recover(); r != nil {
			t.Errorf("%s", r)
		}
	}()
	Divide(6, 0)
}

运行测试

可以使用go test命令运行测试:

go test                # 运行当前包中的所有测试
go test -v             # 详细模式,显示每个测试函数的结果
go test ./...          # 运行当前目录及其子目录中的所有测试
go test -run TestAdd   # 只运行名称匹配"TestAdd"的测试

理解测试输出

使用go test -v ygang.top/demo/calculator在详细模式下进行测试,得到如下结果

yanggang@MacBook demo % go test -v ygang.top/demo/calculator
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestSubtract
--- PASS: TestSubtract (0.00s)
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
=== RUN   TestDivide
--- PASS: TestDivide (0.00s)
=== RUN   TestDivideByZero
    calculator_test.go:48: 除数不能为0
--- FAIL: TestDivideByZero (0.00s)
FAIL
FAIL    ygang.top/demo/calculator       0.353s
FAIL

表格驱动测试

Go社区推崇"表格驱动测试"的模式,这种模式通过创建测试用例表格,使测试更加简洁和可维护。

下面以表格驱动的方式对 Add 方法进行测试

表格驱动测试的主要优势:

func TestAdd_TableDriven(t *testing.T) {
    // 定义测试用例表
    tests := []struct {
        name     string  // 测试名称
        a, b     int     // 输入参数
        expected int     // 期望结果
    }{
        {"正数相加", 3, 2, 5},
        {"负数相加", -3, -2, -5},
        {"正负相加", 3, -2, 1},
        {"零值处理", 0, 0, 0},
        {"大数相加", 1000000, 1000000, 2000000},
    }
    
    // 遍历测试用例表
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; 期望 %d", 
                        tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

执行go test ygang.top/demo/calculator -v -run TestAdd_TableDriven

yanggang@MacBook demo % go test ygang.top/demo/calculator  -v  -run TestAdd_TableDriven
=== RUN   TestAdd_TableDriven
=== RUN   TestAdd_TableDriven/正数相加
=== RUN   TestAdd_TableDriven/负数相加
=== RUN   TestAdd_TableDriven/正负相加
=== RUN   TestAdd_TableDriven/零值处理
=== RUN   TestAdd_TableDriven/大数相加
--- PASS: TestAdd_TableDriven (0.00s)
    --- PASS: TestAdd_TableDriven/正数相加 (0.00s)
    --- PASS: TestAdd_TableDriven/负数相加 (0.00s)
    --- PASS: TestAdd_TableDriven/正负相加 (0.00s)
    --- PASS: TestAdd_TableDriven/零值处理 (0.00s)
    --- PASS: TestAdd_TableDriven/大数相加 (0.00s)
PASS
ok      ygang.top/demo/calculator       (cached)

测试工具函数

testing.T类型提供了多种断言和控制测试流程的方法:

func TestWithHelpers(t *testing.T) {
    // 1. Fatal和Fatalf - 报告失败并立即停止测试执行
    if !checkSetup() {
        t.Fatal("环境设置失败,无法继续测试")
    }
    
    // 2. Error和Errorf - 报告失败但继续执行测试
    result := Multiply(4, 3)
    if result != 12 {
        t.Errorf("Multiply(4, 3) = %d; 期望 12", result)
    }
    
    // 3. Log和Logf - 记录测试信息(仅在详细模式或测试失败时显示)
    t.Log("乘法测试完成")
    
    // 4. Skip和Skipf - 跳过当前测试(例如特定环境不适用)
    if testing.Short() {
        t.Skip("在短模式下跳过此测试")
    }
    
    // 5. Helper - 标记函数为辅助函数(错误报告中正确标注行号)
    t.Helper()
    
    // 继续测试...
}

创建辅助函数

良好的测试代码应当使用辅助函数减少重复代码:

// 通用的断言辅助函数
func assertIntEqual(t *testing.T, got, want int, name string, args ...interface{}) {
    t.Helper() // 标记为辅助函数,错误将定位到调用位置
    
    if got != want {
        if len(args) > 0 {
            t.Errorf("%s(%v) = %d; 期望 %d", name, args, got, want)
        } else {
            t.Errorf("%s = %d; 期望 %d", name, got, want)
        }
    }
}

// 使用辅助函数的测试
func TestWithAssertHelper(t *testing.T) {
    t.Run("Add", func(t *testing.T) {
        assertIntEqual(t, Add(3, 2), 5, "Add", 3, 2)
    })
    
    t.Run("Subtract", func(t *testing.T) {
        assertIntEqual(t, Subtract(5, 2), 3, "Subtract", 5, 2)
    })
    
    t.Run("Multiply", func(t *testing.T) {
        assertIntEqual(t, Multiply(4, 3), 12, "Multiply", 4, 3)
    })
}

基准测试

基准测试基础

什么是基准测试

基准测试(Benchmark)是评估代码性能的测量工具,用于确定代码执行所需的时间和资源。在Go中,基准测试可以帮助我们:

Go中的基准测试框架

Go的testing包不仅支持单元测试,还内置了强大的基准测试功能。基准测试函数遵循以下规则:

package calculator

import "testing"

func BenchmarkAdd(b *testing.B) {
  // 重置计时器(可选)
	b.ResetTimer()
  // b.N由测试框架动态确定,以获得稳定的测量结果
	for i := 0; i < b.N; i++ {
		Add(10, 5)
	}
}

func BenchmarkMultiply(b *testing.B) {
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Multiply(10, 5)
	}
}

运行基准测试

使用go test命令运行基准测试:

go test -bench=.                   # 运行所有基准测试
go test -bench=Add                 # 运行名称匹配"Add"的基准测试
go test -bench=. -benchmem         # 同时显示内存分配统计
go test -bench=. -count=5          # 重复测试5次
go test -bench=. -benchtime=10s    # 将测试时间延长到10秒

基准测试输出示例:

yanggang@MacBook demo % go test -v ygang.top/demo/calculator -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: ygang.top/demo/calculator
cpu: Apple M1 Pro
BenchmarkAdd
BenchmarkAdd-8          1000000000               0.3163 ns/op          0 B/op          0 allocs/op
BenchmarkMultiply
BenchmarkMultiply-8     1000000000               0.3106 ns/op          0 B/op          0 allocs/op
PASS
ok      ygang.top/demo/calculator       0.986s

输出项含义:

性能剖析

性能剖析基础

什么是性能剖析

性能剖析(Profiling)是分析程序执行期间资源使用情况的技术,帮助开发者识别:

Go提供了强大的内置剖析工具,主要通过runtime/pprof包和go tool pprof命令实现。

Go支持的剖析类型

启用性能剖析

启用性能剖析的方法有以下几种:

1、测试时启用:通过go test的命令行参数

go test -cpuprofile=cpu.prof  # CPU剖析
go test -memprofile=mem.prof  # 内存剖析
go test -blockprofile=block.prof  # 阻塞剖析

2、代码中手动启用:通过pprof包API

package main

import (
    "os"
    "runtime/pprof"
    // ...
)

func main() {
    // CPU剖析
    cpuFile, _ := os.Create("cpu.prof")
    pprof.StartCPUProfile(cpuFile)
    defer pprof.StopCPUProfile()
    
    // 执行需要分析的代码
    doSomethingIntensive()
    
    // 内存剖析
    memFile, _ := os.Create("mem.prof")
    defer memFile.Close()
    pprof.WriteHeapProfile(memFile)
}

3、Web服务中启用:使用net/http/pprof

package main

import (
    "net/http"
    _ "net/http/pprof"  // 仅需导入,不需要显式使用
)

func main() {
    // 启动HTTP服务器
    http.ListenAndServe(":8080", nil)
}

// 访问 http://localhost:8080/debug/pprof/ 查看剖析数据

使用pprof分析性能

pprof工具介绍

pprof是Go的性能分析工具,可以分析和可视化剖析数据。它提供了多种视图,包括:

分析CPU剖析数据

假设我们已经生成了CPU剖析文件,现在可以使用pprof工具分析它:

go tool pprof cpu.prof

# 进入交互式模式后的常用命令:
(pprof) top10           # 显示消耗最多CPU的10个函数
(pprof) list functionName  # 显示特定函数的代码和CPU使用情况
(pprof) web             # 在浏览器中打开可视化视图(需安装Graphviz)
(pprof) pdf             # 生成PDF格式的调用图
(pprof) flame           # 生成火焰图(需安装FlameGraph工具)

Web界面示例(需要先安装Graphviz):

go tool pprof -http=:8080 cpu.prof  # 启动Web服务器查看剖析数据

内存分析

内存分析可以帮助我们找出导致大量内存分配的代码:

go test -memprofile=mem.prof
go tool pprof -alloc_objects mem.prof  # 分析对象分配
go tool pprof -alloc_space mem.prof    # 分析分配的内存空间
go tool pprof -inuse_objects mem.prof  # 分析仍在使用的对象
go tool pprof -inuse_space mem.prof    # 分析仍在使用的内存空间

内存泄漏分析,现将两个节点的剖析文件保存

// 在程序的关键点获取内存快照
pprof.WriteHeapProfile(firstFile)
// ...执行操作...
pprof.WriteHeapProfile(secondFile)

然后再比较两个快照找出泄漏

go tool pprof --base firstFile secondFile

阻塞和互斥锁分析

对于并发程序,分析goroutine的阻塞和锁等待情况很重要:

// 在代码中启用阻塞剖析
runtime.SetBlockProfileRate(1)  // 设置阻塞剖析采样率

// 在代码中启用互斥锁剖析
runtime.SetMutexProfileFraction(1)  // 设置互斥锁剖析采样率

启用测试时的阻塞和互斥锁剖析:

go test -blockprofile=block.prof
go test -mutexprofile=mutex.prof

分析剖析数据:

go tool pprof block.prof
go tool pprof mutex.prof