16·工程化入门

单元测试

testtestingassertsubtest

Go 并发编程 - 第16课:单元测试

所属模块:工程化 难度:初级 标签test, testing, assert, subtest 上一课:包与模块 下一课:基准测试与性能分析


1. 学习目标

完成本课后,你将能够:

  1. 掌握 Go 标准库 testing 包编写基本单元测试的方法。
  2. 使用表驱动测试(Table-Driven Tests)高效组织多组测试用例。
  3. 利用子测试(Subtests)对测试进行更精细的组织和控制。
  4. 理解并使用常见的断言方式(包括标准库和第三方库)。
  5. 遵循 Go 项目的最佳实践来组织和运行测试。

2. 核心概念

2.1 什么是单元测试?

单元测试就像是为你的代码函数配备的一个“自动检查作业的机器人”。你提前写好一些测试用例(给定输入和预期输出),然后让这个机器人自动运行你的函数,检查实际输出是否与预期一致。这能帮助你快速发现 Bug,放心地重构代码。

2.2 Go 的测试利器:testing

Go 语言内置了强大的测试工具。你只需要遵循简单的规则,就能编写测试:

  • 测试文件:必须以 _test.go 结尾,例如 mathutils_test.go
  • 测试函数:函数名必须以 Test 开头,后接一个大写字母,例如 TestAdd。它接收一个参数 t *testing.T
  • 断言:使用 t.Errorf, t.Fatalf 等方法报告测试失败。
  • 运行:在项目目录下,执行 go test 命令即可。

2.3 表驱动测试

这是 Go 社区极力推荐的一种测试模式。它将测试输入和预期输出组织在一个“表”(通常是切片或映射)中,然后用循环遍历这个表,为每一行数据运行一次测试逻辑。这使得添加新用例变得极其简单。

2.4 子测试

通过 t.Run(name, func(t *testing.T){...}),你可以在一个主测试函数下创建具有独立命名和生命周期的子测试。这对于测试一个功能的不同场景、或者共享公共设置/清理逻辑非常有用。

2.5 断言库

标准库的 t.Errorf 提供了基本断言,但有时需要更丰富的断言方式(如集合比较、深度比较)。常用的第三方库是 github.com/stretchr/testify/assert,它能让你写出更简洁、更具表达力的断言。


3. 代码示例

3.1 待测试的代码 (mathutils.go)

假设我们有一些简单的数学工具函数。

package mathutils

import (
	"errors"
	"math"
)

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

// Divide 返回两个浮点数的商,如果除数为零则返回错误
func Divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

// IsPrime 检查一个大于1的正整数是否为素数
func IsPrime(n int) bool {
	if n <= 1 {
		return false
	}
	if n <= 3 {
		return true
	}
	if n%2 == 0 || n%3 == 0 {
		return false
	}
	for i := 5; i*i <= n; i += 6 {
		if n%i == 0 || n%(i+2) == 0 {
			return false
		}
	}
	return true
}

// Sqrt 计算平方根,对负数返回错误
func Sqrt(x float64) (float64, error) {
	if x < 0 {
		return 0, errors.New("square root of negative number")
	}
	return math.Sqrt(x), nil
}

3.2 测试文件 (mathutils_test.go)

package mathutils

import (
	"testing"
	"math"

	// 可选:如果想使用 testify 库,取消下面的注释并执行 `go get github.com/stretchr/testify`
	// "github.com/stretchr/testify/assert"
)

// TestAdd 测试 Add 函数的基础用例
func TestAdd(t *testing.T) {
	// 使用标准库进行断言
	result := Add(2, 3)
	if result != 5 {
		t.Errorf("Add(2, 3) = %d; want 5", result)
	}

	result = Add(-1, 1)
	if result != 0 {
		t.Errorf("Add(-1, 1) = %d; want 0", result)
	}
}

// TestDivide_StandardLib 使用标准库和表驱动测试测试 Divide 函数
func TestDivide_StandardLib(t *testing.T) {
	// 定义测试表:每个元素包含输入、期望结果和期望错误
	tests := []struct {
		name     string  // 测试用例名称
		a, b     float64 // 输入
		expected float64 // 期望结果
		wantErr  bool    // 是否期望返回错误
	}{
		{"正数除法", 10, 2, 5.0, false},
		{"负数结果", 10, -2, -5.0, false},
		{"除以零", 5, 0, 0, true},
		{"小数运算", 7.5, 2.5, 3.0, false},
	}

	for _, tt := range tests {
		// 为每个测试用例运行子测试
		t.Run(tt.name, func(t *testing.T) {
			result, err := Divide(tt.a, tt.b)
			// 检查错误是否符合预期
			if (err != nil) != tt.wantErr {
				t.Errorf("Divide(%v, %v) error = %v, wantErr %v", tt.a, tt.b, err, tt.wantErr)
				return
			}
			// 检查结果是否符合预期(使用一个很小的误差范围来处理浮点数精度)
			if !tt.wantErr && math.Abs(result-tt.expected) > 1e-6 {
				t.Errorf("Divide(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected)
			}
		})
	}
}

// TestDivide_Testify 如果使用 testify 库,断言会更简洁
// func TestDivide_Testify(t *testing.T) {
// 	tests := []struct {
// 		name     string
// 		a, b     float64
// 		expected float64
// 		wantErr  bool
// 	}{
// 		{"正常除法", 10, 2, 5.0, false},
// 		{"错误情况", 10, 0, 0, true},
// 	}
//
// 	for _, tt := range tests {
// 		t.Run(tt.name, func(t *testing.T) {
// 			result, err := Divide(tt.a, tt.b)
// 			if tt.wantErr {
// 				assert.Error(t, err)
// 			} else {
// 				assert.NoError(t, err)
// 				assert.InDelta(t, tt.expected, result, 1e-6, "结果应近似相等")
// 			}
// 		})
// 	}
// }

// TestIsPrime 使用表驱动测试素数函数
func TestIsPrime(t *testing.T) {
	primeTests := []struct {
		input    int
		expected bool
	}{
		{-5, false},
		{0, false},
		{1, false},
		{2, true},
		{3, true},
		{4, false},
		{5, true},
		{6, false},
		{7, true},
		{8, false},
		{9, false},
		{10, false},
		{11, true},
		{13, true},
		{97, true},
		{100, false},
	}

	for _, tt := range primeTests {
		t.Run("IsPrime_"+string(rune(tt.input)), func(t *testing.T) {
			result := IsPrime(tt.input)
			if result != tt.expected {
				t.Errorf("IsPrime(%d) = %v; want %v", tt.input, result, tt.expected)
			}
		})
	}
}

// TestSqrt 测试包含错误路径的函数
func TestSqrt(t *testing.T) {
	// 测试正常路径
	result, err := Sqrt(16.0)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if math.Abs(result-4.0) > 1e-6 {
		t.Errorf("Sqrt(16) = %f; want 4.0", result)
	}

	// 测试错误路径
	_, err = Sqrt(-4.0)
	if err == nil {
		t.Error("expected error for Sqrt of negative number, but got nil")
	}
}

4. 实践练习

练习 1: 为 Multiply 函数编写测试

  1. mathutils.go 文件中添加一个函数 Multiply(a, b int) int,返回两个整数的乘积。
  2. mathutils_test.go 中为 Multiply 函数编写测试 TestMultiply
  3. 至少包含 2 个测试用例(例如 Multiply(3, 4)Multiply(-2, 5))。
  4. 运行 go test -run TestMultiply -v 查看结果。

练习 2: 使用表驱动测试 Max 函数

  1. 编写函数 Max(a, b int) int,返回两个整数中的较大值。
  2. 要求:使用表驱动测试的方式为其编写测试 TestMax
  3. 测试表至少包含 4 种情况:a > b, a < b, a == b, ab 为负数。
  4. 预期:所有用例通过。

练习 3: 为 ParseAge 函数编写测试

  1. 编写一个函数 ParseAge(s string) (int, error)
    • 尝试将字符串 s 解析为整数年龄。
    • 如果解析成功且年龄在 0 到 150 之间,返回该年龄和 nil 错误。
    • 如果解析失败,或年龄不在范围内,返回 0 和一个具体的错误信息(例如 "invalid age format""age out of range (0-150)")。
  2. 使用表驱动测试子测试,为 ParseAge 编写全面的测试 TestParseAge
  3. 测试表应覆盖以下情况:
    • 正常的年龄字符串(如 "25"
    • 无效格式(如 "abc""25.5"
    • 年龄为边界值("0", "150"
    • 年龄越界("-1", "151"
    • 空字符串
  4. 预期:所有用例通过。

5. 常见错误

  1. 测试文件名错误:文件名不以 _test.go 结尾,导致 go test 无法识别。
  2. 测试函数签名错误:函数名不以 Test 开头,或没有接收 *testing.T 参数。
  3. 逻辑错误导致测试永真:断言条件写反,或者测试本身的逻辑有误,导致即使函数行为错误,测试也会通过(“绿灯假象”)。
  4. 依赖外部状态:测试依赖于外部数据库、网络服务或系统时间,导致测试不可重复、脆弱。应该尽量使用 mock 或 stub。
  5. 不检查错误:对于返回 error 的函数,测试时忘记检查错误是否为 nil,或者检查了错误但逻辑不正确。
  6. 浮点数比较错误:直接使用 == 比较浮点数,由于精度问题可能导致测试不稳定。应使用一个小的误差范围(delta)进行比较。
  7. 运行全部测试却以为只运行了一个:在项目根目录运行 go test ./... 会运行所有包的测试。使用 go test -run TestSpecific 来运行特定的测试函数。

6. 小结

本课我们学习了 Go 语言中单元测试的核心知识:

  • 测试基础:遵循 _test.go 文件名和 TestXxx 函数名的约定,使用 testing 包提供的方法进行断言。
  • 表驱动测试:将测试用例组织成表格形式,通过循环高效运行,这是 Go 社区的最佳实践,易于维护和扩展。
  • 子测试:使用 t.Run() 为相关测试用例创建具有描述性名称的子测试,使测试输出更清晰,也便于调试和选择性运行。
  • 断言工具:标准库 t.Errorf 够用,但第三方库如 testify/assert 能提供更强大、更简洁的断言能力。
  • 测试哲学:好的单元测试应该是独立的、可重复的、快速的,并且能清晰地验证函数在各种边界条件下的行为。

掌握单元测试是成为一名优秀工程师的必经之路。它不仅是一种保障代码质量的工具,更是一种驱动你写出更清晰、更模块化代码的设计思想。现在就去为你的代码编写测试吧!

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「基准测试与性能分析」 以巩固所学知识。