Skip to content

测试基础:testing 包与表驱动测试

Go 语言从设计之初就把「测试」当作一等公民来对待。标准库自带 testing 包,工具链原生支持 go test 命令,无需引入任何第三方框架就能写出工业级的单元测试。本篇面向已经掌握 Go 基础语法的开发者,系统讲解 testing 包的核心用法、表驱动测试模式、子测试与并行测试,以及测试覆盖率的基本使用。

一、Go 测试哲学:测试即代码

很多语言(比如 Java、Python)需要借助 JUnit、pytest 这样的外部框架才能写测试,而 Go 选择了完全不同的路线:测试就是普通的 Go 代码。这一哲学体现在以下几个方面:

  • 测试文件与源文件同目录foo.go 的测试写在 foo_test.go 里,两者放在同一个包下,紧邻放置便于查找。
  • 测试函数遵循命名约定:以 Test 开头、参数为 *testing.T 的函数会被 go test 自动识别并执行,无需注册。
  • 不使用注解或装饰器:Go 没有注解机制,所有测试组织都靠命名约定与代码结构表达,简单直接。
  • 工具链原生支持go testgo 工具的内置子命令,配合 -v-cover-bench 等参数即可完成绝大多数测试需求。

这种设计的好处是:任何 Go 项目,无论规模大小,打开就能测。你不需要先学习一个框架的 DSL,也不需要配置复杂的运行环境。测试代码与生产代码使用同一套语法、同一套工具链、同一种思维方式。

一个经验法则:如果一个函数难以测试,往往说明它的设计有问题(例如依赖过重、副作用过多)。在 Go 中,可测试性与代码质量是高度相关的。

二、testing 包基础

testing 包位于标准库,导入路径是 testing。它提供了三个核心类型:

类型用途触发命令
*testing.T功能测试(单元测试、集成测试)go test
*testing.B基准测试(性能测试)go test -bench=.
*testing.FFuzz 测试(Go 1.18+)go test -fuzz=Fuzz

本篇聚焦功能测试,基准测试在后续篇章专门讲解。

测试文件必须满足以下规则:

  1. 文件名以 _test.go 结尾,例如 user_test.go
  2. 测试函数签名必须是 func TestXxx(t *testing.T),其中 Xxx 首字母必须大写,但不能以小写字母开头。
  3. 测试文件可以与被测代码在同一个包(内部测试,可访问未导出成员),也可以在 package_test 这样的外部测试包中(外部测试,只能访问导出成员)。

下面是一份最小可运行的测试示例。先准备被测代码:

go
package mathutil

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

// Sub 返回 a 减 b 的差。
func Sub(a, b int) int {
	return a - b
}

再写对应的测试文件 mathutil_test.go

go
package mathutil

import "testing"

func TestAdd(t *testing.T) {
	got := Add(1, 2)
	if got != 3 {
		t.Errorf("Add(1, 2) = %d, want 3", got)
	}
}

func TestSub(t *testing.T) {
	got := Sub(5, 3)
	if got != 2 {
		t.Errorf("Sub(5, 3) = %d, want 2", got)
	}
}

执行 go test 即可运行测试:

bash
go test

输出示例:

text
PASS
ok      example.com/mathutil    0.003s

加上 -v 参数可以看到每个用例的执行情况:

bash
go test -v

输出:

text
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestSub
--- PASS: TestSub (0.00s)
PASS
ok      example.com/mathutil    0.003s

三、第一个测试函数:TestXxx

测试函数的命名直接影响测试输出的可读性。建议遵循以下规范:

  • 函数名以 Test 开头,后接被测函数或行为的名称,例如 TestAddTestUserLogin
  • 名称应该描述「被测什么」,而不是「测试什么」。TestAddTestAddFunc 更好。
  • 当一个函数有多种行为需要分别测试时,使用子测试(见后文)而不是拆分成多个顶层测试函数。

下面是一个稍完整的示例,被测代码是一个字符串工具:

go
package stringutil

import "strings"

// Reverse 返回字符串的反转结果。
func Reverse(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

// IsPalindrome 判断字符串是否是回文。
func IsPalindrome(s string) bool {
	s = strings.ToLower(s)
	return s == Reverse(s)
}

// Truncate 把字符串截断到指定长度,超出部分用省略号替代。
func Truncate(s string, maxLen int) string {
	if len(s) <= maxLen {
		return s
	}
	if maxLen <= 3 {
		return s[:maxLen]
	}
	return s[:maxLen-3] + "..."
}

对应测试:

go
package stringutil

import "testing"

func TestReverse(t *testing.T) {
	got := Reverse("hello")
	want := "olleh"
	if got != want {
		t.Errorf("Reverse(\"hello\") = %q, want %q", got, want)
	}
}

func TestIsPalindrome(t *testing.T) {
	cases := []struct {
		input string
		want  bool
	}{
		{"racecar", true},
		{"hello", false},
		{"a", true},
		{"", true},
	}
	for _, c := range cases {
		if got := IsPalindrome(c.input); got != c.want {
			t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
		}
	}
}

func TestTruncate(t *testing.T) {
	if got := Truncate("hello world", 8); got != "hello..." {
		t.Errorf("Truncate(\"hello world\", 8) = %q, want \"hello...\"", got)
	}
	if got := Truncate("hi", 5); got != "hi" {
		t.Errorf("Truncate(\"hi\", 5) = %q, want \"hi\"", got)
	}
}

四、t.Error、t.Fatal、t.Skip

*testing.T 提供了多种标记测试失败的方式,三者的关键区别在于「失败后是否继续执行当前函数」:

方法行为适用场景
t.Error(args...)标记失败并继续执行(等价于 t.Log + t.Fail收集多个错误一起展示
t.Errorf(format,...)同上,但支持格式化收集多个错误
t.Fatal(args...)标记失败并立即停止当前测试函数后续断言依赖前面,再测无意义
t.Fatalf(format,...)同上,支持格式化同上
t.Skip(args...)跳过当前测试,标记为 SKIP环境不满足、长期测试、平台特异
t.Skipf(format,...)同上,支持格式化同上

下面演示三者的差异:

go
package mathutil_test

import (
	"math"
	"testing"
)

func TestErrorCollect(t *testing.T) {
	// t.Errorf 会标记失败但继续执行,便于一次看到多个失败点
	if got := math.Sqrt(4); got != 2 {
		t.Errorf("Sqrt(4) = %v, want 2", got)
	}
	if got := math.Sqrt(9); got != 3 {
		t.Errorf("Sqrt(9) = %v, want 3", got)
	}
	if got := math.Sqrt(16); got != 4 {
		t.Errorf("Sqrt(16) = %v, want 4", got)
	}
	// 即使上面有失败,这里仍会执行
	t.Log("TestErrorCollect 完成")
}

func TestFatalImmediate(t *testing.T) {
	// 假设需要先初始化资源,失败则后续断言无意义
	if 1 != 2 {
		t.Fatal("前置条件失败:1 应该等于 2,后续断言不再执行")
	}
	// 这一行永远不会执行
	t.Log("这行不会打印")
}

func TestSkipExample(t *testing.T) {
	// 跳过长测试或环境不支持的测试
	if testing.Short() {
		t.Skip("短模式下跳过耗时测试")
	}
	// 只有非短模式才会执行到这里
	for i := 0; i < 1000000; i++ {
		_ = i * i
	}
	t.Log("耗时测试执行完成")
}

执行 go test -v -short 可以看到 SKIP 的效果:

text
=== RUN   TestErrorCollect
    mathutil_test.go:15: TestErrorCollect 完成
--- PASS: TestErrorCollect (0.00s)
=== RUN   TestFatalImmediate
    mathutil_test.go:27: 前置条件失败:1 应该等于 2,后续断言不再执行
--- FAIL: TestFatalImmediate (0.00s)
=== RUN   TestSkipExample
    mathutil_test.go:35: 短模式下跳过耗时测试
--- SKIP: TestSkipExample (0.00s)

五、测试组织:子测试 t.Run

当被测函数有多种输入组合或多种场景时,把所有断言塞进一个 TestXxx 函数会导致失败时定位困难。t.Run 让你在一个顶层测试内部组织多个子测试,每个子测试有独立的名字和独立的失败状态。

go
package stringutil_test

import "testing"

func TestReverseTable(t *testing.T) {
	t.Run("ASCII 字符串", func(t *testing.T) {
		if got := Reverse("hello"); got != "olleh" {
			t.Errorf("got %q, want %q", got, "olleh")
		}
	})
	t.Run("空字符串", func(t *testing.T) {
		if got := Reverse(""); got != "" {
			t.Errorf("got %q, want \"\"", got)
		}
	})
	t.Run("中文 UTF-8", func(t *testing.T) {
		if got := Reverse("你好"); got != "好你" {
			t.Errorf("got %q, want %q", got, "好你")
		}
	})
	t.Run("奇数长度", func(t *testing.T) {
		if got := Reverse("abcde"); got != "edcba" {
			t.Errorf("got %q, want %q", got, "edcba")
		}
	})
}

子测试可以嵌套,也可以单独运行某个子测试:

bash
go test -v -run TestReverseTable/中文_UTF-8

输出:

text
=== RUN   TestReverseTable
=== RUN   TestReverseTable/中文_UTF-8
--- PASS: TestReverseTable/中文_UTF-8 (0.00s)
--- PASS: TestReverseTable (0.00s)
PASS

-run 参数支持正则,灵活选择要跑的子测试,对调试单个用例非常有用。

六、表驱动测试模式(Table-Driven Tests)

表驱动测试是 Go 社区最经典的测试模式。核心思想:把测试用例抽象成一张「表」(切片),每行包含输入和期望输出,然后用一个循环统一执行断言。这种模式的好处是:

  • 新增用例只需加一行,无需复制粘贴测试骨架。
  • 断言逻辑集中,避免代码重复。
  • 天然适合子测试,每行一个独立的 t.Run,失败隔离。
  • 可读性强,测试意图一眼可读。

下面是 IsPalindrome 的标准表驱动写法:

go
package stringutil_test

import "testing"

func TestIsPalindromeTable(t *testing.T) {
	cases := []struct {
		name  string // 子测试名称
		input string
		want  bool
	}{
		{"纯英文回文", "racecar", true},
		{"非回文", "hello", false},
		{"单字符", "a", true},
		{"空字符串", "", true},
		{"带大小写", "RaceCar", true},
		{"中文回文", "上海自来水来自海上", true},
		{"混合非回文", "abc123", false},
	}

	for _, c := range cases {
		c := c // 捕获循环变量(Go 1.22 之前需要,1.22+ 默认每轮独立)
		t.Run(c.name, func(t *testing.T) {
			got := IsPalindrome(c.input)
			if got != c.want {
				t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
			}
		})
	}
}

关于 c := c:在 Go 1.22 之前,for range 循环变量是共享的,闭包捕获会出问题,需要手动 c := c 制造一份拷贝。Go 1.22 起 for 循环变量每次迭代都是独立的,这行可以省略。但为了兼容旧版本,许多项目仍然保留这种写法。

表驱动测试的常见变体是「期望错误」的用例:

go
package mathutil

import "errors"

// Divide 返回 a/b,当 b 为 0 时返回错误。
func Divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("divisor cannot be zero")
	}
	return a / b, nil
}
go
package mathutil_test

import (
	"errors"
	"testing"
)

func TestDivide(t *testing.T) {
	cases := []struct {
		name    string
		a, b    float64
		want    float64
		wantErr error
	}{
		{"正常除法", 10, 2, 5, nil},
		{"负数除法", -10, 2, -5, nil},
		{"小数除法", 1, 3, 0.3333333333333333, nil},
		{"除零错误", 10, 0, 0, errors.New("divisor cannot be zero")},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Divide(c.a, c.b)
			if c.wantErr != nil {
				if err == nil {
					t.Fatalf("期望返回错误,得到 nil")
				}
				if err.Error() != c.wantErr.Error() {
					t.Fatalf("错误信息不匹配: got %q, want %q", err.Error(), c.wantErr.Error())
				}
				return
			}
			if err != nil {
				t.Fatalf("未期望错误: %v", err)
			}
			if got != c.want {
				t.Errorf("Divide(%v, %v) = %v, want %v", c.a, c.b, got, c.want)
			}
		})
	}
}

七、测试并行:t.Parallel

t.Parallel() 让一个测试(或子测试)声明自己可以与其他并行测试同时运行。Go 会在所有串行测试执行完后,并行执行所有调用了 t.Parallel() 的测试。使用要点:

  1. t.Parallel() 通常放在测试函数的第一行(在子测试里则放在闭包第一行)。
  2. 并行测试之间不能共享可变状态,否则会有数据竞争。
  3. 并行度默认等于 GOMAXPROCS,可通过 -parallel N 参数控制。
go
package stringutil_test

import (
	"sync/atomic"
	"testing"
)

func TestParallelSubtests(t *testing.T) {
	var counter int64

	cases := []struct {
		name  string
		input string
		want  string
	}{
		{"case1", "abc", "cba"},
		{"case2", "12345", "54321"},
		{"case3", "Go语言", "言语oG"},
		{"case4", "!@#$", "$#@!"},
	}

	for _, c := range cases {
		c := c
		t.Run(c.name, func(t *testing.T) {
			t.Parallel() // 声明这个子测试可以与其他并行测试同时跑
			atomic.AddInt64(&counter, 1)

			got := Reverse(c.input)
			if got != c.want {
				t.Errorf("Reverse(%q) = %q, want %q", c.input, got, c.want)
			}
		})
	}
	t.Logf("counter 最终值(不保证): %d", atomic.LoadInt64(&counter))
}

并行测试适合 IO 密集型场景(如网络请求、数据库查询),但不适合 CPU 密集型短测试(并行反而增加调度开销)。在写库或公共组件时,推荐默认开启并行以提前发现数据竞争。

配合 go test -race 可以在并行场景下检测数据竞争:

bash
go test -race -v

八、测试覆盖率:go test -cover

覆盖率(Coverage)是指「被测试执行到的代码行数 / 总代码行数」的比例。Go 工具链内置了覆盖率统计,使用非常简单:

bash
go test -cover

输出示例:

text
PASS
coverage: 85.7% of statements
ok      example.com/stringutil   0.005s

如果想要更详细的覆盖率报告,可以生成 profile 文件并用 go tool cover 查看:

bash
# 生成 coverage profile
go test -coverprofile=coverage.out

# 在浏览器中查看 HTML 报告
go tool cover -html=coverage.out

# 也可以输出函数级别的覆盖率
go tool cover -func=coverage.out

go tool cover -func 输出示例:

text
example.com/stringutil/Reverse.go:4:     Reverse        100.0%
example.com/stringutil/IsPalindrome.go:9: IsPalindrome   100.0%
example.com/stringutil/Truncate.go:16:    Truncate        80.0%
total:                                  statements     85.7%

覆盖率不是越高越好。100% 覆盖率只能保证「每一行都被执行过」,但无法保证「所有边界条件都被覆盖」。把覆盖率当作发现未测试代码的工具,而不是终极目标。一般建议核心业务逻辑覆盖率不低于 80%,工具类代码可以更高。

九、运行测试:go test 常用参数

go test 的参数非常丰富,下面列出最常用的几个:

参数作用
-v显示每个测试的详细运行过程
-run regexp只运行名字匹配正则的测试
-count N每个测试运行 N 次,用于检测偶发失败
-cover显示覆盖率
-coverprofile file输出覆盖率到文件
-race开启数据竞争检测
-short跳过耗时测试(在测试中通过 testing.Short() 判断)
-timeout duration设置整个测试的超时时间,默认 10 分钟
-parallel N设置并行测试的并行度
-bench regexp同时运行匹配的基准测试
-benchmem基准测试中报告内存分配情况
-cpu 1,2,4在不同 GOMAXPROCS 下运行基准测试
-failfast第一个测试失败后立即停止
-v -run xxx -count=1调试单个测试时最常用的组合

几个常用组合示例:

bash
# 详细运行所有测试,并显示覆盖率
go test -v -cover

# 只运行名字包含 "Reverse" 的测试,并显示详细过程
go test -v -run Reverse

# 检测数据竞争,运行 3 次确保稳定
go test -race -count=3

# 设置 30 秒超时,避免死锁测试拖慢 CI
go test -timeout 30s

# 跑基准测试并显示内存分配
go test -bench=. -benchmem

十、完整示例:字符串工具的测试

下面把前面散落的示例整合成一个完整可运行的小工程。目录结构:

text
stringutil/
├── go.mod
├── stringutil.go
└── stringutil_test.go

go.mod

text
module example.com/stringutil

go 1.21

stringutil.go

go
// Package stringutil 提供一组字符串处理工具函数。
package stringutil

import (
	"strings"
	"unicode"
)

// Reverse 返回字符串的反转结果,按 rune 反转以正确处理 UTF-8。
func Reverse(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

// IsPalindrome 判断字符串是否是回文(忽略大小写与非字母数字字符)。
func IsPalindrome(s string) bool {
	var letters []rune
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			letters = append(letters, unicode.ToLower(r))
		}
	}
	for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
		if letters[i] != letters[j] {
			return false
		}
	}
	return true
}

// Truncate 把字符串截断到指定长度,超出部分用省略号替代。
func Truncate(s string, maxLen int) string {
	if len(s) <= maxLen {
		return s
	}
	if maxLen <= 3 {
		return s[:maxLen]
	}
	return s[:maxLen-3] + "..."
}

// CountWords 统计字符串中的英文单词数量。
func CountWords(s string) int {
	return len(strings.Fields(s))
}

// IsBlank 判断字符串是否为空或仅包含空白字符。
func IsBlank(s string) bool {
	return strings.TrimSpace(s) == ""
}

stringutil_test.go

go
package stringutil_test

import "testing"

func TestReverse(t *testing.T) {
	cases := []struct {
		name, input, want string
	}{
		{"ASCII", "hello", "olleh"},
		{"空字符串", "", ""},
		{"单字符", "a", "a"},
		{"中文", "你好", "好你"},
		{"混合", "Go语言", "言语oG"},
		{"符号", "!@#$", "$#@!"},
		{"奇数长度", "abcde", "edcba"},
		{"偶数长度", "abcdef", "fedcba"},
	}

	for _, c := range cases {
		c := c
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			if got := Reverse(c.input); got != c.want {
				t.Errorf("Reverse(%q) = %q, want %q", c.input, got, c.want)
			}
		})
	}
}

func TestIsPalindrome(t *testing.T) {
	cases := []struct {
		name  string
		input string
		want  bool
	}{
		{"纯英文回文", "racecar", true},
		{"非回文", "hello", false},
		{"单字符", "a", true},
		{"空字符串", "", true},
		{"带大小写", "RaceCar", true},
		{"带标点", "A man, a plan, a canal: Panama", true},
		{"中文回文", "上海自来水来自海上", true},
		{"混合非回文", "abc123", false},
	}

	for _, c := range cases {
		c := c
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			if got := IsPalindrome(c.input); got != c.want {
				t.Errorf("IsPalindrome(%q) = %v, want %v", c.input, got, c.want)
			}
		})
	}
}

func TestTruncate(t *testing.T) {
	cases := []struct {
		name   string
		input  string
		maxLen int
		want   string
	}{
		{"无需截断", "hi", 5, "hi"},
		{"刚好等于", "hello", 5, "hello"},
		{"需要截断", "hello world", 8, "hello..."},
		{"极短截断", "hello world", 3, "hel"},
		{"超短截断", "hello world", 2, "he"},
		{"空字符串", "", 5, ""},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := Truncate(c.input, c.maxLen); got != c.want {
				t.Errorf("Truncate(%q, %d) = %q, want %q",
					c.input, c.maxLen, got, c.want)
			}
		})
	}
}

func TestCountWords(t *testing.T) {
	cases := []struct {
		input string
		want  int
	}{
		{"hello world", 2},
		{"one", 1},
		{"", 0},
		{"   ", 0},
		{"  multiple   spaces  ", 2},
		{"a b c d e", 5},
		{"tab\tseparated", 2},
		{"newline\nseparated", 2},
	}

	for _, c := range cases {
		if got := CountWords(c.input); got != c.want {
			t.Errorf("CountWords(%q) = %d, want %d", c.input, got, c.want)
		}
	}
}

func TestIsBlank(t *testing.T) {
	cases := []struct {
		input string
		want  bool
	}{
		{"", true},
		{"   ", true},
		{"\t\n", true},
		{"hello", false},
		{" hello ", false},
	}

	for _, c := range cases {
		if got := IsBlank(c.input); got != c.want {
			t.Errorf("IsBlank(%q) = %v, want %v", c.input, got, c.want)
		}
	}
}

运行整套测试:

bash
go test -v -cover -race

预期输出(节选):

text
=== RUN   TestReverse
=== RUN   TestReverse/ASCII
=== RUN   TestReverse/中文
...
--- PASS: TestReverse (0.00s)
=== RUN   TestIsPalindrome
...
PASS
coverage: 100.0% of statements
ok      example.com/stringutil   0.008s

十一、常见错误与避坑指南

初学 Go 测试时,以下几个坑比较常见:

1. 忘记捕获循环变量

在 Go 1.22 之前,下面的写法会有数据竞争问题:

go
for _, c := range cases {
    t.Run(c.name, func(t *testing.T) {
        t.Parallel()
        // 这里 c 永远是最后一个用例!
        if got := Reverse(c.input); got != c.want {
            t.Errorf("got %q", got)
        }
    })
}

修复方法:在循环体第一行加 c := c

2. 在 t.Run 之外使用 t.Parallel

t.Parallel() 必须在测试函数内部、子测试闭包内部调用,否则没有效果。顶层 TestXxx 函数里直接 t.Parallel() 也是合法的,但子测试里的并行必须在子测试闭包里声明。

3. 测试函数互相依赖执行顺序

go
func TestCreateUser(t *testing.T) { ... }
func TestGetUser(t *testing.T) {
    // 依赖 TestCreateUser 先执行并创建了用户
}

这是反模式。Go 不保证测试执行顺序,每个测试必须独立可运行。需要前置数据时,使用 setup 函数或 TestMain。

4. 用 t.Fatal 过多导致信息丢失

t.Fatal 会立即终止当前测试函数,导致后续断言不执行。如果一个测试有多个独立的断言,应该用 t.Errorf 收集所有失败信息,便于一次看完所有问题。

5. 测试覆盖率追求 100% 而忽略边界

100% 覆盖率不代表覆盖了所有边界条件。比如下面的代码:

go
func Abs(n int) int {
    if n < 0 {
        return -n
    }
    return n
}

Abs(-1)Abs(1) 即可达到 100% 覆盖,但漏掉了 Abs(math.MinInt32) 这种边界情况。

十二、小结

本篇系统介绍了 Go 测试的基础知识,重点掌握以下几点:

  1. 测试即代码:Go 不需要外部框架,go test 内置一切,门槛极低。
  2. testing 包三剑客*testing.T(功能测试)、*testing.B(基准测试)、*testing.F(Fuzz 测试)。
  3. 失败标记三种方式t.Error(继续)、t.Fatal(停止)、t.Skip(跳过)。
  4. 子测试 t.Run:组织多种场景,便于单独运行和失败定位。
  5. 表驱动测试:Go 社区最经典的测试模式,新增用例只需加一行。
  6. t.Parallel:声明并行执行,配合 -race 检测数据竞争。
  7. 覆盖率go test -covergo tool cover 是发现未测试代码的工具。
  8. go test 参数-v-run-count-race-timeout 是日常调试的五大法宝。

下一篇我们会学习如何用 testify 让断言更简洁、更可读,并介绍测试套件、Setup/TearDown 等更高级的组织方式。


至此,你已经掌握了 Go 测试的基础工具链。把这些概念用到你的项目里,先给一个核心函数补上一份表驱动测试,再逐步扩展到整个模块。测试代码与生产代码同等重要——它们同样需要可读、可维护、可演进。