Docs Vault

在实际开发中,不仅要开发功能,而且还要确保这些功能稳定可靠,并且拥有一个不错的性能,要确保这些,就要对代码进行测试。测试分为很多种,例如:功能测试、性能测试、集成测试、端到端测试、单元测试等。


对于开发者来说,需要执行的测试种类一般是单元测试和性能测试。除此之外,Go 还提供了其他类型的测试,例如:模糊测试、示例测试。本节课会详细介绍开发者可以执行的测试种类,以及如何编写和执行测试用例。


提示:
本节课及下节课最终源码位于 miniblog 项目的 feature/s30 分支。


Go 单元测试的现状与发展


在实际开发中,许多开发者并不习惯随时编写单元测试用例。事实上,很多项目几乎没有单元测试,而绝大多数项目的单元测试覆盖率常年低于 10%。这并不是因为单元测试不重要,而是因为在许多情况下,功能需求的开发往往被赋予更高的优先级。以下是我对编写单元测试用例的一些思考:

  1. 单元测试的编写很重要,但不一定需要在开发功能的同时完成。很多时候,我们需要在功能开发进度和单元测试编写之间找到平衡;
  2. 在开发过程中,如果需要测试某个代码块时,可以顺便将测试代码整理为单元测试用例,这样既节省时间,又提高代码质量;
  3. 单元测试用例对后期代码的维护至关重要。在项目上线后,如果条件允许,建议尽量补全单元测试用例,以便为后续迭代提供保障;
  4. 编写单元测试用例不应成为 KPI 工程,在编写单元测试用例时,应该确保单元测试用例的有效性。


随着大语言模型(LLM,Large Language Model)能力的不断增强,开发者如今可以借助 GPT 类工具直接生成单元测试用例。这些由 GPT 生成的测试用例,在覆盖范围和准确性上甚至会优于人工编写。此外,许多企业内部也会有 AI 类平台,用于智能生成单元测试用例。借助 AI 能力生成单元测试用例已经被广泛采用。


未来,随着 LLM 能力的进一步提升,单元测试用例的编写极有可能完全由 AI 工具自动完成,不仅能够生成新的测试用例,还能根据代码变化实时更新测试用例。这将大幅减少开发者在测试编写上的投入,让他们能够专注于核心开发工作,彻底解放他们在单元测试编写中的开发负担。


Go 标准库 testing 包介绍



Go 语言自带测试框架 testing,可以用于编写单元测试用例和性能测试用例,并通过 go test 命令运行测试用例。


go test 命令在运行测试用例时,以 Go 包为单位进行测试。运行时需要指定包名,例如:go test <包名>。如果未指定包名,测试将默认作用于运行命令时所在的包。go test 执行时会遍历以 _test.go 结尾的源码文件,并运行其中以 Test、Benchmark、Example、Fuzz 开头的测试函数。这些源码文件需满足以下规则:

  1. 文件名要求:文件名必须以 _test.go 结尾,且建议与被测试的源文件位于同一个包中;
  2. 测试用例函数规范:测试用例函数需以 Test、Benchmark、Example、Fuzz 开头;
  3. 测试执行顺序:测试用例的执行顺序按照源码中的定义顺序依次进行;
  4. 单元测试函数:函数名称形如 TestXxx(t *testing.T),其中 Xxx 部分为任意字母数字组合,首字母需大写。例如:Testlogger 是错误的函数名,TestLogger 是正确的函数名。参数 testing.T 可以用于记录错误或测试状态;
  5. 性能测试函数:函数名称形如 BenchmarkXxx (b *testing.B),函数以 b.N 作为循环次数,其中 N 值会动态变化;
  6. 示例函数:示例函数名称形如 ExampleXxx(),没有参数,执行后将其输出与注释 // Output: 中声明的结果进行对比。


testing.T 提供了丰富的方法来管理测试过程和结果,常用方法如下:

  1. 输出测试信息:t.Log、t.Logf 两个方法可以用来输出测试信息;
  2. 输出测试失败信息:t.Error、t.Errorf 两个方法可以用来输出测试异常或失败时的信息;
  3. 记录致命错误:t.Fatal、t.Fatalf 两个方法用来记录致命错误,并退出测试;
  4. 标记测试失败:t.Fail 方法用来将当前测试标记为失败,但测试不会退出。t.Failed 方法用来检查当前测试是否已标记为失败;
  5. 终止测试:t.FailNow 用于标记当前测试失败,并立即终止当前测试函数的执行;
  6. 跳过测试:t.Skip、t.Skipf 两个方法可用于跳过当前测试函数的执行,并记录一条备注信息。t.Skipped 方法可用于检测当前测试是否已被跳过;
  7. 并行执行测试:t.Parallel 可将测试标记为支持并行运行。


性能测试过程中,需要重点注意 BenchmarkXxx 函数,其参数 testing.B 用于设置动态变化的循环次数(b.N 值),例如:

func BenchmarkResourceID_New(b *testing.B) {
    // 性能测试
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        userID := rid.UserID
        _ = userID.New(uint64(i))
    }
}


此外,示例函数 ExampleXxx 在编写时需保证输出与注释 // Output: 内容一致,以通过验证,例如:

func ExampleResourceID_String() {
    // 定义一个资源标识符,例如用户资源
    userID := rid.UserID

    // 调用String方法,将ResourceID类型转换为字符串类型
    idString := userID.String()

    // 输出结果
    fmt.Println(idString)

    // Output:
    // user
}


Go 测试用例编写


在实际项目开发中,最常编写的是单元测试用例,其次是性能测试用例。在某些场景下,还可能需要编写模糊测试用例和示例测试用例。


本节会详细介绍如何编写单元测试、性能测试、模糊测试和示例测试。


单元测试


新建 internal/pkg/rid/rid_test.go 文件,在文件中添加 ResourceID 数据类型 String() 方法的单元测试用例:

func TestResourceID_String(t *testing.T) {
    // 测试 UserID 转换为字符串
    userID := rid.UserID
    assert.Equal(t, "user", userID.String(), "UserID.String() should return 'user'")

    // 测试 PostID 转换为字符串
    postID = rid.PostID
    assert.Equal(t, "post", postID.String(), "PostID.String() should return 'post'")
}


在编写单元测试用例时,经常需要对比期望值和实际值是否一致,可以直接编写代码来对比,例如:

if expected != actual {
    t.Error("actual value did not match the expected value")
} 


但更建议使用优秀的断言包来对比。在 Go 生态中,比较常用的断言包是 github.com/stretchr/testify/assert。


assert 包,提供了一组丰富的断言函数,用于简化 Go 语言中的单元测试用例编写。通过 assert 包,开发者可以用更直观的方式验证测试结果,如检查值相等、布尔值匹配、集合包含关系、错误状态等,从而极大地提测试代码的可读性和开发效率。


实现单元测试用例后,可以通过执行 go test 命令运行测试用例。go test 命令支持不同的命令行选项,从而实现多种测试效果。常用的 go test 命令如下。

(1)执行默认的测试用例

在 internal/pkg/rid 目录下执行命令 go test:

$ go test .
ok  github.com/onexstack/miniblog/internal/pkg/rid0.008s

(2)查看更详细的执行信息

要查看更详细的执行信息可以执行 go test -v:

$ go test -v .
=== RUN   TestResourceID_String
--- PASS: TestResourceID_String (0.00s)
=== RUN   TestResourceID_New
--- PASS: TestResourceID_New (0.00s)
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid(cached)

(3)执行测试 N 次

如果要执行测试 N 次可以使用 -count N 命令行选项:

$ go test -v -count 2
=== RUN   TestResourceID_String
--- PASS: TestResourceID_String (0.00s)
=== RUN   TestResourceID_New
--- PASS: TestResourceID_New (0.00s)
=== RUN   TestResourceID_String
--- PASS: TestResourceID_String (0.00s)
=== RUN   TestResourceID_New
--- PASS: TestResourceID_New (0.00s)
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid0.022s


通过上述测试输出可知,每个测试用例被执行了 2 次。

(4)只运行指定的单测用例


此外,你还可以通过指定 -run 参数(-run 参数支持正则表达式)只运行指定的单测用例:

$ go test -v -run TestResourceID_String
=== RUN   TestResourceID_String
--- PASS: TestResourceID_String (0.00s)
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid0.008s


性能测试


性能测试也叫基准测试,是 Go 项目开发中,非常核心的测试用例类型。Go 开发者也需要掌握如何编写性能测试用例。


在 internal/pkg/rid/rid_test.go 文件中,新增 BenchmarkResourceID_New 性能测试用例函数,代码如下:

func BenchmarkResourceID_New(b *testing.B) {
    // 性能测试
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        userID := rid.UserID
        _ = userID.New(uint64(i))
    }
}


上述代码定义了一个基准测试函数,用于测量 userID.New 方法的性能表现。函数通过 b.ResetTimer() 重置计时器,确保计时只统计核心测试代码的执行时间,然后在一个循环中根据 b.N 的值多次调用 userID.New(uint64(i)) 方法,以模拟高频调用场景并评估其性能。


性能测试函数的名称必须以 Benchmark 开头,例如 BenchmarkXxx 或 Benchmark_Xxx。默认情况下,go test 不会执行性能测试函数,需通过指定参数 -test.bench 来运行,-test.bench 后需接正则表达式,例如 go test -test.bench=".*" 表示运行所有性能测试函数。在性能测试中,应在循环体中使用 testing.B.N 来多次循环执行测试代码。


在编写性能测试用例时,如果用例需要进行一些耗时的准备工作以测试目标函数,可以在准备工作完成后调用 b.ResetTimer() 方法重置计时器。


实现性能测试用例后,可以执行 go test 命令来运行性能测试用例。在 internal/pkg/rid 目录下,执行 go test -test.bench=".*" 命令来运行性能测试用例:

$ go test -test.bench=".*"
goos: linux
goarch: amd64
pkg: github.com/onexstack/miniblog/internal/pkg/rid
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkResourceID_New-32      126488      9794 ns/op
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid1.355s


上述测试用例执行结果显示,BenchmarkResourceID_New 用例执行了 126488 次,每次执行的平均时间是 9794 纳秒。1.355s 表示测试用例总的执行时间。


在运行性能测试用例时,还可以通过 -benchtime 命令行选项,来指定性能测试用例的运行时间和运行次数,确保性能测试结果更加稳定,Go 会根据指定的运行时间和运行次数动态调整运行次数(b.N),以确保测试运行的总时长接近设定值。二者的指定方式如下:

  1. -benchtime=1x:指定运行次数为 1 次(可改为任意次数,例如 -benchtime=10x 表示运行 10 次);
  2. -benchtime=5s:指定基准测试运行时间为 5 秒(可改为其他时间,例如 -benchtime=100ms 表示运行 100 毫秒)。


运行以下命令,并分别指定性能测试用例的运行时间为 30s、运行次数为 100000 次:

$ go test -benchtime=30s -test.bench="^BenchmarkResourceID_New$"
goos: linux
goarch: amd64
pkg: github.com/onexstack/miniblog/internal/pkg/rid
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkResourceID_New-32     5219535      6910 ns/op
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid43.048s
$ go test -benchtime=100000x -test.bench="^BenchmarkResourceID_New$"
goos: linux
goarch: amd64
pkg: github.com/onexstack/miniblog/internal/pkg/rid
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkResourceID_New-32      100000      7045 ns/op
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid0.714s


在实际运行性能测试用例时,通常会指定运行时间而非运行次数。


模糊测试


Fuzzing 是一种自动化测试技术,它通过不断生成输入数据来测试程序中的漏洞。Go 的 Fuzzing 功能采用覆盖率智能指导机制,遍历被模糊测试的代码,从而发现缺陷并将相关问题报告给用户。由于模糊测试能够有效覆盖开发者经常忽视的边缘场景,因此在发现安全漏洞和缺陷方面具有特别重要的价值。Go 语言从 1.18 版本开始正式支持模糊测试用例。


单元测试存在一定局限性,因为每个测试输入都需要由开发者明确指定并添加到单元测试用例中。而 Fuzzing 的优势之一在于,它可以基于开发者指定的测试输入作为基础数据,进一步自动生成新的随机测试数据,从而发现单元测试未覆盖的边界情况。


然而,Fuzzing 也有其局限性。在单元测试中,由于测试输入是固定的,因此开发者可以明确知道调用 Reverse 函数(该函数用于将输入字符串反转)后,每个输入字符串的预期输出应该是什么,从而在单元测试代码中判断 Reverse 函数的执行结果是否与预期相符。但在 Fuzzing 中,由于测试数据是随机生成的,我们无法预先知道输出结果。Fuzzing 与 Go 已有的单元测试框架和性能测试框架是互为补充的,二者并不存在替代关系。


在 internal/pkg/rid/rid_test.go 文件中,新增一下 FuzzResourceID_New 模糊测试用例实现,代码如代码清单 12-1 所示。

代码清单 12-1 模糊用例实现

func FuzzResourceID_New(f *testing.F) {
    // 添加预置测试数据
    f.Add(uint64(1))      // 添加一个种子值counter为1
    f.Add(uint64(123456)) // 添加一个较大的种子值

    f.Fuzz(func(t *testing.T, counter uint64) {
        // 测试UserID的New方法
        result := rid.UserID.New(counter)

        // 断言结果不为空
        assert.NotEmpty(t, result, "The generated unique identifier should not be empty")

        // 断言结果必须包含资源标识符前缀
        assert.Contains(t, result, rid.UserID.String()+"-", "The generated unique identifier should contain the correct prefix")

        // 断言前缀不会与uniqueStr部分重叠
        splitParts := strings.SplitN(result, "-", 2)
        assert.Equal(t, rid.UserID.String(), splitParts[0], "The prefix part of the result should correctly match the UserID")

        // 断言生成的ID具有固定长度(基于NewCode的配置)
        if len(splitParts) == 2 {
            assert.Equal(t, 6, len(splitParts[1]), "The unique identifier part should have a length of 6")
        } else {
            t.Errorf("The format of the generated unique identifier does not meet expectation")
        }
    })
}

模糊测试用例的函数名必须以 Fuzz 开头,例如 FuzzXxx 或 Fuzz_Xxx,函数需接收一个 testing.F 类型的参数。在上述测试用例中,使用 f.Add 方法告知 Fuzzing 引擎所需的数据类型和顺序。f.Fuzz 函数需要传入一个用于模糊测试的函数,其中首个参数必须是 testing.T,第二个参数为希望由 Fuzzing 引擎生成的随机数据类型。


上述模糊测试用例针对 rid.UserID.New 方法进行了验证,确保其在不同输入(counter 值)情况下的输出结果符合预期。测试首先提供了两个种子值(1 和 123456),由 Fuzzing 框架动态生成更多随机输入,测试方法在多样化数据下的行为。通过调用 New 方法生成唯一标识符,结合一系列断言验证了结果的正确性,包括验证输出不为空、是否包含合规的资源标识符前缀(如 user-)、结构是否符合分隔要求,以及唯一编码部分长度是否为固定的 6 位字符。


可以使用以下两种方法来运行模糊测试用例。

1)只使用种子语料库,而不生成随机测试数据

运行命令如下:

$ go test -v -run=FuzzResourceID_New
=== RUN   FuzzResourceID_New
=== RUN   FuzzResourceID_New/seed#0
=== RUN   FuzzResourceID_New/seed#1
--- PASS: FuzzResourceID_New (0.00s)
    --- PASS: FuzzResourceID_New/seed#0 (0.00s)
    --- PASS: FuzzResourceID_New/seed#1 (0.00s)
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid0.011s

上述运行方式只能使用种子语料库,而不会生成随机测试数据。这种方法可用于验证种子语料库中的测试数据是否能够通过测试。所谓语料库(seed corpus),是指用户提供的一组语料,Fuzzing 引擎会以这些语料为基础生成随机数据。它实际上是一个样板,借助这个样板,Fuzzing 引擎能够确定要生成的随机数据类型。

(2)基于种子语料库生成随机测试数据

运行命令如下:

$ go test -v -fuzz=FuzzResourceID_New
=== RUN   TestResourceID_String
--- PASS: TestResourceID_String (0.00s)
=== RUN   TestResourceID_New
--- PASS: TestResourceID_New (0.00s)
=== RUN   FuzzResourceID_New
fuzz: elapsed: 0s, gathering baseline coverage: 0/11 completed
fuzz: elapsed: 0s, gathering baseline coverage: 11/11 completed, now fuzzing with 32 workers
fuzz: elapsed: 3s, execs: 376578 (125495/sec), new interesting: 9 (total: 20)
^Cfuzz: elapsed: 4s, execs: 446878 (130336/sec), new interesting: 9 (total: 20)
--- PASS: FuzzResourceID_New (3.54s)
=== NAME
PASS
ok  github.com/onexstack/miniblog/internal/pkg/rid3.565s


需要注意,模糊测试的执行时间由开发者自行决定。如果代码非常健壮,无论如何更换随机数据,测试都能通过,那么 Fuzzing 将一直执行下去,直到发现错误,或开发者手动使用 Ctrl^C 终止。


与模糊测试相关的 go test 参数包括以下三个:

  1. -fuzztime:指定模糊测试目标在退出前的总执行时间或迭代次数,默认值为永不结束;
  2. -fuzzminimizetime:指定模糊测试目标在最小化尝试时的执行时间或迭代次数,默认值为 60 秒。可以通过将该参数设置为 0 来禁用最小化尝试;
  3. -parallel:指定同时执行的模糊化数量,默认值为 $GOMAXPROCS。需要注意,在进行模糊测试时,设置-cpu 参数无效。


示例测试


在 Go 中,测试不仅仅是为了验证程序功能是否正确,还可以通过 Example 函数编写示例测试,向开发者展示代码的用法及其行为输出。这些示例代码通常易于理解,既可以作为文档的一部分,又可以作为功能验证的一种手段。Example 类型的测试函数以 Example 为前缀命名。


新建 internal/pkg/rid/example_test.go 文件,代码如下:

func ExampleResourceID_String() {
    // 定义一个资源标识符,例如用户资源
    userID := rid.UserID

    // 调用String方法,将ResourceID类型转换为字符串类型
    idString := userID.String()

    // 输出结果
    fmt.Println(idString)

    // Output:
    // user
}

上述代码是一个针对 ResourceID 类型的 String 方法的 Go 示例测试。// Output: 声明了程序的标准输出结果。运行示例测试时,测试工具会区域匹配实际输出与声明的 Output 是否一致。如果存在不一致,测试将失败。需要注意的是:

  1. 如果实际输出结果中包含随机部分,则需要确保测试输出是一致的。例如,可以通过设置随机种子(seed)或构造简单的输出结果来保证输出的一致性;
  2. 如果输出结果无法固定,可以省略 // Output:,这样示例仅用于展示使用方法而不会验证输出。


测试覆盖率分析



在编写单元测试时,应尽量考虑全面,覆盖所有可能的测试用例,但有时仍可能遗漏一些测试用例。Go 提供了 cover 工具用于统计测试覆盖率。测试覆盖率可以通过以下两条命令完成:

  1. go test -coverprofile=cover.out:在测试文件目录下运行测试并统计测试覆盖率;
  2. go tool cover -func=cover.out:分析覆盖率文件,用于检查哪些函数未被测试,或者哪些函数内部的分支未完全覆盖。cover 工具通过执行代码的行数与总行数的比例来表示覆盖率。


进入 internal/pkg/rid 目录,执行以下命令,来测试单元测试覆盖率:

$ cd internal/pkg/rid
$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
github.com/onexstack/miniblog/internal/pkg/rid/rid.go:25:String100.0%
github.com/onexstack/miniblog/internal/pkg/rid/rid.go:30:New100.0%
github.com/onexstack/miniblog/internal/pkg/rid/salt.go:18:Salt100.0%
github.com/onexstack/miniblog/internal/pkg/rid/salt.go:29:ReadMachineID72.7%
github.com/onexstack/miniblog/internal/pkg/rid/salt.go:50:readPlatformMachineID75.0%
total:(statements)81.8%

可以看到 github.com/onexstack/miniblog/internal/pkg/rid 包的单元测试覆盖率为 81.8%。


添加 Makefile 测试规则


在编写了测试用例之后,可以在项目根目录下通过运行以下命令,来运行所有的测试用例:

$ go test ./...


更建议的方法是添加一个 test 和 cover Makefile 伪目标,分别用来运行整个项目的测试用例和单元测试覆盖率测试,test 和 cover 伪目标如下:

.PHONY: test
test: # 执行单元测试.
    @echo "===========> Running unit tests"
    @mkdir -p $(OUTPUT_DIR)
    @go test -race -cover \
        -coverprofile=$(OUTPUT_DIR)/coverage.out \
        -timeout=10m -shuffle=on -short \
        -v `go list ./...|egrep -v 'tools|vendor|third_party'`

.PHONY: cover
cover: test ## 执行单元测试,并校验覆盖率阈值.
    @echo "===========> Running code coverage tests"
    @go tool cover -func=$(OUTPUT_DIR)/coverage.out | awk -v target=$(COVERAGE) -f $(PROJ_ROOT_DIR)/scripts/coverage.awk


为了在执行 make 命令时,默认运行覆盖率测试,需要将 cover 目标添加到 all 目标中:

all: tidy format build cover add-copyright


因为 cover 目标依赖 test 目标,在执行 cover 目标时,会自动执行 test 目标,所以 all 目标依赖中,只需要添加 cover 目标即可。


小结(AI 自动生成并人工审核)


本文系统地介绍了在 Go 项目开发中如何编写和执行多种类型的测试用例,以确保代码的功能性、稳定性和性能表现。


文章首先强调了单元测试的重要性及其在代码质量保障中的作用,并指出编写单元测试的有效性比覆盖率更为关键。随着 AI 工具的发展,单元测试的生成效率和覆盖范围也得到了显著提升。


接着,文章详细讲解了 Go 标准库 testing 包的使用,包括单元测试、性能测试、模糊测试和示例测试的编写方法及运行方式,特别是性能测试和模糊测试在发现潜在问题和边界情况中的价值。


同时,文章还介绍了 Go 提供的测试覆盖率工具,通过统计代码覆盖率帮助开发者发现未测试的代码路径。


最后,建议通过 Makefile 集成测试规则,简化测试执行流程,提升开发效率。通过这些方法,开发者能够构建出高质量、稳定且性能优越的 Go 应用。