README.md 7.0 KB

第 14 课:单元测试专题

学习顺序

  1. 阅读 main.go,先理解被测的 add 和 parseScore。
  2. 在 main_test.go 中只敲 TestAdd,执行一次测试。
  3. 再敲 TestAddTable,把结构体切片、循环、匿名函数串起来。
  4. 最后敲 TestParseScore,学习验证错误和边界情况。

共享根目录的 go.mod,仅使用标准库,无需安装测试框架。本课暂不使用并行测试、mock、基准测试或模糊测试。

1. 测试如何启动

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. 表驱动测试

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

这是第八课学过的“结构体切片”,每条记录表示一组输入与期望结果,不是特殊测试语法。

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 表示“期望本次调用发生错误”,不是错误文本本身。

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. 测试命令

以下命令在仓库根目录执行:

# 运行本课所有测试,显示每个用例和日志
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. 编译普通程序与编译测试程序

普通程序仍可以编译运行,在仓库根目录执行:

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

普通程序预期输出:

2 + 3 = 5
转换后的成绩:85
运行自动测试:go test -v ./lessons/14_testing(在仓库根目录执行)

也可以用 go run ./lessons/14_testing,但它只运行 main,不运行 _test.go 中的测试。

go test 本身也需要先编译。若想单独保留测试可执行文件:

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,不能只复制测试却指望自动访问父目录函数。

下一课学习包与模块,把可复用函数拆到自定义包中并正确导入。

上一课:函数进阶 · 学习总览