# 第 14 课:单元测试专题 ## 学习顺序 1. 阅读 [main.go](./main.go),先理解被测的 add 和 parseScore。 2. 在 [main_test.go](./main_test.go) 中只敲 TestAdd,执行一次测试。 3. 再敲 TestAddTable,把结构体切片、循环、匿名函数串起来。 4. 最后敲 TestParseScore,学习验证错误和边界情况。 共享根目录的 `go.mod`,仅使用标准库,无需安装测试框架。本课暂不使用并行测试、mock、基准测试或模糊测试。 ## 1. 测试如何启动 ```go package main import "testing" func TestAdd(t *testing.T) { got := add(2, 3) want := 5 if got != want { t.Fatalf("实际=%d,期望=%d", got, want) } } ``` - 测试文件以 `_test.go` 结尾。 - 普通测试函数形如 `func TestXxx(t *testing.T)`,Test 后的名字不能以小写字母开头。本课统一使用 TestAdd 这样的名称。 - `testing` 是标准库包,T 是它提供的类型,`*testing.T` 是指针类型,t 是参数名。 - go test 负责生成测试入口、调用这些函数、传入 t;不需要在 main 中调用测试。 - 同包测试使用 `package main`,可以直接访问本课未导出的 add 和 parseScore。 - 测试文件不会被普通 go build 编译进应用程序。 一次测试的核心是:准备输入 → 调用函数 → 比较实际与期望 → 不一致就报告失败。got、want、tt 都只是惯用变量名,不是关键字。 ## 2. Logf、Errorf 与 Fatalf | 方法 | 标记失败 | 是否继续当前测试 | | --- | --- | --- | | `t.Logf(...)` | 否,只记录信息 | 继续 | | `t.Errorf(...)` | 是 | 继续 | | `t.Fatalf(...)` | 是 | 停止当前测试 | 它们支持格式字符串。测试正常执行完且未标记失败,就是通过,不需要额外调用成功方法。 Fatalf 在子测试中只停止当前子测试的执行,其他用例通常仍会运行;不要把它理解为立即结束整个测试进程。普通 `return` 也能结束当前测试函数,但不会自动把它标记为失败。 不要在测试中仅用 fmt.Println 输出“失败”来代替 t.Errorf / t.Fatalf,否则 go test 可能仍报告通过。 ## 3. 表驱动测试 ```go tests := []struct { name string a int b int want int }{ {name: "positive", a: 2, b: 3, want: 5}, {name: "zero", a: 0, b: 0, want: 0}, } ``` 这是第八课学过的“结构体切片”,每条记录表示一组输入与期望结果,不是特殊测试语法。 ```go for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := add(tt.a, tt.b) if got != tt.want { t.Errorf("实际=%d,期望=%d", got, tt.want) } }) } ``` 按层理解: 1. range 每次取出一条测试记录 tt。 2. 外层 t.Run 创建有名字的子测试。 3. 第二个参数是匿名函数,由框架调用;不在末尾加自行调用的 `()`。 4. 内层 t 是子测试自己的管理器,遮蔽外层同名参数。 5. 匿名函数使用外层 tt,本质上是上节课学习的闭包。 6. 最后的 `})` 分别关闭匿名函数体和 Run 的调用参数列表。 本课没有调用 t.Parallel,子测试回调结束后才继续下一次循环,先按顺序理解。并发执行以及旧版本 Go 循环变量捕获的差异留到后续,不要随意添加并行语句。 ## 4. 验证错误与边界 TestParseScore 的 wantErr 表示“期望本次调用发生错误”,不是错误文本本身。 ```go got, err := parseScore(tt.input) hasErr := err != nil if hasErr != tt.wantErr { t.Fatalf("错误=%v,期望发生错误=%t", err, tt.wantErr) } if tt.wantErr { return } ``` 先比较是否发生错误与预期是否一致: - 期望成功却失败:测试失败。 - 期望失败却成功:测试失败。 - 确实发生预期错误:本用例通过错误检查,结束,不再使用无效结果。 - 确实成功:继续核对结果数值。 本例覆盖 0、100 两个合法边界,以及 -1、101、非整数文本和空文本。错误只检查是否存在,不逐字匹配标准库错误信息。它不验证错误的具体分类;更精细的错误断言留到错误进阶。 ## 5. 测试命令 以下命令在**仓库根目录**执行: ```powershell # 运行本课所有测试,显示每个用例和日志 go test -v ./lessons/14_testing # 强制重新执行,不复用成功的测试缓存 go test -count=1 -v ./lessons/14_testing # 只运行最简单的 TestAdd,不匹配 TestAddTable go test -v ./lessons/14_testing -run '^TestAdd$' # 只运行一个指定子测试 go test -v ./lessons/14_testing -run '^TestParseScore$/^upper_bound$' # 查看本课语句覆盖率 go test -cover ./lessons/14_testing # 运行全仓库测试 go test ./... ``` -run 接收正则表达式,不是普通字符串包含查找;`^`、`$` 分别约束开头与结尾。子测试用 `/` 分层筛选。 显示 `(cached)` 表示复用了之前的成功测试结果,不是本次重新执行。覆盖率高不代表逻辑一定正确;本课只测试业务函数,没有覆盖 main 的终端展示,因此总覆盖率不会是 100%。 ## 6. 编译普通程序与编译测试程序 普通程序仍可以编译运行,在仓库根目录执行: ```powershell New-Item -ItemType Directory -Force bin/14_testing | Out-Null go build -o ./bin/14_testing/testing_demo.exe ./lessons/14_testing .\bin\14_testing\testing_demo.exe ``` 普通程序预期输出: ```text 2 + 3 = 5 转换后的成绩:85 运行自动测试:go test -v ./lessons/14_testing(在仓库根目录执行) ``` 也可以用 `go run ./lessons/14_testing`,但它只运行 main,不运行 `_test.go` 中的测试。 go test 本身也需要先编译。若想单独保留测试可执行文件: ```powershell go test -c -o ./bin/14_testing/lesson14.test.exe ./lessons/14_testing .\bin\14_testing\lesson14.test.exe '-test.v' ``` 直接运行测试产物时使用 `-test.v` 等测试二进制参数,而不是 go test 的 `-v`。在 PowerShell 中给带点的参数加引号,确保原样传入。修改源码或测试后需要重新编译。当前目录的 bin 已在前面的命令中创建。 ## 动手练习 - [ ] 只运行 TestAdd,找到 Logf 输出。 - [ ] 把 want 从 5 改成 6,观察 FAIL 和报错文件行号,再恢复。 - [ ] 将 Fatalf 临时换成 Errorf,并在其后加 Logf,比较是否继续执行;不要在 Fatalf 后写不可达的代码。 - [ ] 在加法测试表里增加一条自己的输入。 - [ ] 给成绩表添加 `" 85 "`,期望错误,因为 Atoi 不会自动去除空白。 - [ ] 只运行 upper_bound 子测试。 - [ ] 全部恢复为正确测试,执行 `go test -count=1 ./...`,确认通过。 本课测试代码应直接放在本课目录。若另建 practice 子目录,需要把被测函数和对应测试一起放进去,再执行 `go test -v ./lessons/14_testing/practice`,不能只复制测试却指望自动访问父目录函数。 下一课学习包与模块,把可复用函数拆到自定义包中并正确导入。 [上一课:函数进阶](../13_function_values/README.md) · [学习总览](../../README.md)