Go的单元测试
面试中被问到了go的单元测试,完全答不上来。紧急学习一下。
本文内容非原创,是对b站up主CodeJR视频的学习总结笔记
一、 基本概念
- 在Go语言中,单元测试主要依赖内置的
go test命令,无需引入额外的第三方工具或库。go test命令会根据特定的命名约定自动查找和执行测试代码。 - 如果代码运行的实际环境比较复杂,比如业务涉及的网络拓扑错综艰深、依赖数据库或者其他外部服务等情况,那么使用go test肯定是不够的,它更适合小范围快速验证、局部精细化测试的场景。
二、 Go单元测试文件与方法的命名规则
- Go语言对单元测试的代码组织和命名有明确的约定:
- 位置相同: 测试代码文件必须与被测试的功能代码文件位于同一目录下。不需要专门创建一个名为
test的独立目录来存放测试文件。 - 文件命名: 测试代码文件的文件名必须以
_test.go结尾。例如,要测试calc.go文件中的功能,相应的测试文件应命名为calc_test.go。 - 方法命名: 测试函数必须以
Test为前缀开头,后面通常(非强制)跟着被测试的函数名或描述性名称(采用驼峰命名法)。例如,测试Abs函数的测试方法应命名为TestAbs。
三、 单元测试示例:编写与运行基础测试
-
假设我们有一个名为
calc的包,其中包含一个calc.go文件,该文件定义了Abs(取绝对值)和Add(加法)两个函数:package calc - func Abs(x int) int { if x >= 0 { return x } return -x } - func Add(a, b int) int { return a + b } -
步骤1:创建测试文件 在
calc目录下(与calc.go同级),创建文件calc_test.go。 -
步骤2:编写测试函数 在
calc_test.go中,编写测试Abs和Add函数的代码:package calc - import ( "testing" ) - // 测试Abs函数 func TestAbs(t *testing.T) { result := Abs(-1) // 调用被测试函数 if result != 1 { // 断言:检查结果是否符合预期 t.Errorf("Abs(-1) = %d; want 1", result) // 如果不符合,报告错误 } } - // 测试Add函数 func TestAdd(t *testing.T) { result := Add(1, 2) // 调用被测试函数 if result != 3 { // 断言:检查结果是否符合预期 t.Errorf("Add(1, 2) = %d; want 3", result) // 如果不符合,报告错误 } }testing.T:测试框架提供的类型,用于管理测试状态和报告错误。t.Errorf(format string, args ...interface{}):当测试失败时,用于格式化输出错误信息。 -
步骤3:运行测试 在包含
calc_test.go的目录(即calc目录)中打开命令行或终端。基础运行: 输入命令
go test。如果所有测试通过,输出类似:PASS ok your/package/calc 0.001s详细输出: 使用
-v(verbose) 参数可以查看每个运行的测试函数及其结果:go test -v === RUN TestAbs --- PASS: TestAbs (0.00s) === RUN TestAdd --- PASS: TestAdd (0.00s) PASS ok your/package/calc 0.001s -
步骤4:运行特定测试 如果想只运行某个特定的测试函数(例如
TestAbs),可以使用-run参数并指定测试函数名的正则表达式(通常直接写函数名后缀即可):go test -run TestAbs -v输出将只显示TestAbs的执行情况。
四、 测试覆盖率分析
- 测试覆盖率衡量了测试用例执行了功能代码的多少比例。Go提供了工具来分析测试覆盖率。
- 步骤1:查看整体覆盖率
在测试目录下运行命令:
go test -cover输出会显示测试的覆盖率百分比:
上面的PASS coverage: 75.0% of statements ok your/package/calc 0.001s75.0%表示当前测试用例覆盖了calc.go中75%的代码行,还有25%的代码没有被测试到。 - 步骤2:生成覆盖率报告文件
要查看具体哪些代码没有被覆盖,需要生成覆盖率报告文件:
go test -cover -coverprofile=cover.out-coverprofile=cover.out:将覆盖率数据输出到文件cover.out中。 - 步骤3:生成可读性报告
cover.out文件本身不易阅读。使用go tool cover命令将其转换为HTML格式:
执行此命令后,会自动打开默认浏览器,展示一个高亮显示的HTML报告。go tool cover -html=cover.out- 绿色: 表示被测试覆盖到的代码行。
- 红色: 表示未被测试覆盖到的代码行。
- 示例分析:
假设在
Abs函数中,报告显示if x >= 0 { return x }这一行被标记为红色。这表明测试只传入了负数(如-1),执行了else分支(return -x),而没有传入非负数(如0或正数)来执行if分支。
- 步骤4:提高覆盖率
为了覆盖缺失的分支,在
TestAbs中增加一个新的测试用例:再次运行func TestAbs(t *testing.T) { // 原有测试负数 result := Abs(-1) if result != 1 { t.Errorf("Abs(-1) = %d; want 1", result) } - // 新增测试非负数(如1) result = Abs(1) if result != 1 { t.Errorf("Abs(1) = %d; want 1", result) } }go test -cover,覆盖率应该提升到100%。
五、 测试组 (Test Table) 的使用
- 当需要测试一个函数在多种输入情况下的行为时,为每种情况单独写一个测试函数或在一个函数里写多段重复代码会很繁琐。测试组(也称为表驱动测试)是一种更好的方式。
- 概念: 将多个测试用例(输入值和期望输出值)组织在一个数据结构(通常是切片或数组)中,然后在一个测试函数里循环遍历这些用例进行测试。这样方便添加、删除或修改测试用例。
- 重构
TestAbs使用测试组:func TestAbs(t *testing.T) { // 定义测试用例结构体类型 type test struct { input int output int } // 创建测试用例切片 (测试组) tests := []test{ {input: -1, output: 1}, // 用例1: 输入-1,期望输出1 {input: 0, output: 0}, // 用例2: 输入0,期望输出0 {input: 1, output: 1}, // 用例3: 输入1,期望输出1 {input: 10, output: 10}, // 用例4: 输入10,期望输出10 } // 遍历测试组 for _, tc := range tests { // 对每个测试用例执行测试 result := Abs(tc.input) // 调用函数 if result != tc.output { // 检查结果 t.Errorf("Abs(%d) = %d; want %d", tc.input, result, tc.output) // 报告错误 } } } - 优点:
- 这样测试用例集中管理,结构清晰。新增用例只需在切片中添加一行。删除或修改用例同样方便。
- 运行测试:
运行
go test,所有用例都会被执行。如果某个用例失败(例如故意将{input: 10, output: 10}改成{input: 10, output: 1}),测试会失败,但报告只会指出TestAbs失败,无法直接看出是哪个具体的用例失败,尤其是在测试组包含大量用例时。
六、 子测试 (Subtest) 的使用
- 为了解决测试组中难以定位具体失败用例的问题,Go 1.7+ 引入了子测试(
t.Run)。 - 概念: 在测试函数内部,使用
t.Run(name string, f func(*testing.T))来定义和运行子测试。每个子测试都有自己的名称,在测试报告中会独立显示通过或失败。 - 重构
TestAbs使用子测试:func TestAbs(t *testing.T) { // 定义测试用例结构体类型 type test struct { input int output int } // 创建测试用例映射 (使用Map方便命名) tests := map[string]test{ "negative": {input: -1, output: 1}, // 用例1: 命名为"negative" "zero": {input: 0, output: 0}, // 用例2: 命名为"zero" "positive": {input: 1, output: 1}, // 用例3: 命名为"positive" "ten": {input: 10, output: 1}, // 用例4: 命名为"ten" (故意设错期望值) } // 遍历测试用例Map for name, tc := range tests { // 为每个用例定义一个子测试 t.Run(name, func(t1 *testing.T) { result := Abs(tc.input) // 调用函数 if result != tc.output { // 检查结果 t1.Errorf("Abs(%d) = %d; want %d", tc.input, result, tc.output) // 报告错误 (使用t1) } }) } } - 运行子测试:
使用
go test -v运行测试:=== RUN TestAbs === RUN TestAbs/negative === RUN TestAbs/zero === RUN TestAbs/positive === RUN TestAbs/ten --- FAIL: TestAbs (0.00s) --- PASS: TestAbs/negative (0.00s) --- PASS: TestAbs/zero (0.00s) --- PASS: TestAbs/positive (0.00s) --- FAIL: TestAbs/ten (0.00s) calc_test.go:XX: Abs(10) = 10; want 1 FAIL exit status 1 FAIL your/package/calc 0.002s - 优点:
- 清晰定位: 测试报告明确显示
TestAbs下的哪个子测试(如ten)失败了。 - 独立运行: 可以使用
-run参数精确运行某个子测试,例如go test -run TestAbs/ten。 - 更好的组织结构: 可以按逻辑分组管理测试用例。
七、 总结
- 规则: 测试文件(
_test.go)与功能代码同目录,测试函数以Test开头。 - 基础测试: 使用
go test运行测试,-v查看详情,-run运行指定测试。 - 覆盖率: 使用
-cover查看整体覆盖率,结合-coverprofile和go tool cover -html生成可视化报告定位未覆盖代码。 - 测试组: 使用数据结构组织多个测试用例,循环执行,便于用例管理。
- 子测试: 使用
t.Run定义子测试,提供更清晰的测试报告和用例定位能力,推荐用于管理多个用例的场景。