Procházet zdrojové kódy

feat: 新增第十四课单元测试专题与表驱动例程

zhuts před 3 dny
rodič
revize
df9ecf428c

+ 8 - 3
README.md

@@ -49,9 +49,13 @@
     ├── 12_switch_defer/    # 第 12 课:switch 与 defer
     │   ├── README.md       # 多分支、延迟调用与执行顺序
     │   └── main.go         # 指令选择、成绩判断和 defer 示例
-    └── 13_function_values/ # 第 13 课:函数进阶
-        ├── README.md       # 函数值、匿名函数、闭包与 t.Run 预习
-        └── main.go         # 回调计算、独立计数器和 defer 对比
+    ├── 13_function_values/ # 第 13 课:函数进阶
+    │   ├── README.md       # 函数值、匿名函数、闭包与 t.Run 预习
+    │   └── main.go         # 回调计算、独立计数器和 defer 对比
+    └── 14_testing/         # 第 14 课:单元测试专题
+        ├── README.md       # 测试语法、子测试、错误断言和运行命令
+        ├── main.go         # 被测函数与普通程序入口
+        └── main_test.go    # 基础测试、表驱动测试和边界用例
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -73,6 +77,7 @@
 | 11 | [错误处理](./lessons/11_errors/README.md) | error、nil、errors.New、fmt.Errorf、错误传递 | 已建立 |
 | 12 | [switch 与 defer](./lessons/12_switch_defer/README.md) | 多分支、延迟调用、后进先出、参数求值 | 已建立 |
 | 13 | [函数进阶](./lessons/13_function_values/README.md) | 函数值、匿名函数、函数参数、闭包 | 已建立 |
+| 14 | [单元测试专题](./lessons/14_testing/README.md) | testing.T、表驱动、t.Run、错误断言、测试命令 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 184 - 0
lessons/14_testing/README.md

@@ -0,0 +1,184 @@
+# 第 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)

+ 35 - 0
lessons/14_testing/main.go

@@ -0,0 +1,35 @@
+package main
+
+import (
+	"fmt"
+	"strconv"
+)
+
+// add 是最简单的被测函数。本课只用较小整数,不讨论整数溢出。
+func add(a, b int) int {
+	return a + b
+}
+
+// parseScore 将文本转换为成绩,失败时返回错误。
+func parseScore(text string) (int, error) {
+	score, err := strconv.Atoi(text)
+	if err != nil {
+		return 0, err
+	}
+	if score < 0 || score > 100 {
+		return 0, fmt.Errorf("成绩 %d 无效,应在 0~100 之间", score)
+	}
+	return score, nil
+}
+
+// main 只是正常程序示例。go test 不会调用这里的 main。
+func main() {
+	fmt.Printf("2 + 3 = %d\n", add(2, 3))
+	score, err := parseScore("85")
+	if err != nil {
+		fmt.Println("转换失败:", err)
+		return
+	}
+	fmt.Printf("转换后的成绩:%d\n", score)
+	fmt.Println("运行自动测试:go test -v ./lessons/14_testing(在仓库根目录执行)")
+}

+ 79 - 0
lessons/14_testing/main_test.go

@@ -0,0 +1,79 @@
+package main
+
+import "testing"
+
+// 1. 最基础测试:准备输入 -> 调用函数 -> 比较实际值与期望值。
+// 测试函数以 Test 开头,接收 *testing.T,没有返回值。
+func TestAdd(t *testing.T) {
+	got := add(2, 3) // got:实际得到的结果,是普通变量名。
+	want := 5        // want:期望结果,同样不是关键字。
+
+	if got != want {
+		// Fatalf:记录失败信息、标记失败,并停止当前测试。
+		t.Fatalf("add(2, 3):实际=%d,期望=%d", got, want)
+	}
+	// Logf 只记录日志,不会把测试标记为失败;通过时用 -v 查看日志。
+	t.Logf("检查通过:实际=%d,期望=%d", got, want)
+}
+
+// 2. 表驱动测试:把多组输入和期望结果放到结构体切片里。
+func TestAddTable(t *testing.T) {
+	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},
+		{name: "negative", a: -2, b: -3, want: -5},
+		{name: "mixed", a: -2, b: 3, want: 1},
+	}
+
+	for _, tt := range tests {
+		// Run 的参数:子测试名称、执行子测试的函数值。
+		// 匿名函数没有末尾的 (),由测试框架负责调用。
+		t.Run(tt.name, func(t *testing.T) {
+			got := add(tt.a, tt.b)
+			if got != tt.want {
+				// Errorf:记录并标记失败,但继续执行当前测试后面的代码。
+				t.Errorf("add(%d, %d):实际=%d,期望=%d", tt.a, tt.b, got, tt.want)
+			}
+		})
+	}
+}
+
+// 3. 测试有 error 返回值的函数:成功路径和失败路径都要覆盖。
+func TestParseScore(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		want    int
+		wantErr bool
+	}{
+		{name: "normal", input: "85", want: 85, wantErr: false},
+		{name: "lower_bound", input: "0", want: 0, wantErr: false},
+		{name: "upper_bound", input: "100", want: 100, wantErr: false},
+		{name: "below_range", input: "-1", wantErr: true},
+		{name: "above_range", input: "101", wantErr: true},
+		{name: "invalid_text", input: "abc", wantErr: true},
+		{name: "empty", input: "", wantErr: true},
+		{name: "decimal", input: "80.5", wantErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := parseScore(tt.input)
+			hasErr := err != nil
+			if hasErr != tt.wantErr {
+				t.Fatalf("parseScore(%q):错误=%v,期望发生错误=%t", tt.input, err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return // 已确认发生预期错误,本用例不再检查无效结果。
+			}
+			if got != tt.want {
+				t.Errorf("parseScore(%q):实际=%d,期望=%d", tt.input, got, tt.want)
+			}
+		})
+	}
+}