Tutorials

Go 测试综合指南

Read this post in other languages:

本文由外部贡献者撰写。

Alexandre Couëdelo

Alexandre Couëdelo

Alexandre 是一位复杂系统工程和管理专家。 他在职业生涯开始时就拥抱了 DevOps 文化,为加拿大一家领先金融机构的数字化转型做出贡献。 他热衷于 DevOps 革命和工业工程。

GitHub Twitter

测试是开发过程的重要部分,也是软件开发生命周期的关键部分。 它可以确保应用程序正常运行和满足客户需求。 本文将涵盖关于 Go 测试的所有须知事项。 我们将从一个简单的测试函数开始,通过更多工具和策略帮助您掌握 Go 中的测试。

您将详细了解许多测试模式,例如用于更好地组织测试用例的表驱动测试、用于验证性能的基准测试,以及用于探索边缘用例并发现错误的模糊测试。

您还将了解来自标准测试软件包及其辅助函数的工具,以及代码覆盖率如何显示正在测试的代码量。 您也将了解 Testify,这是一个可以提高测试可读性的断言和模拟库。

您可以在此 GitHub 仓库中找到所有代码示例。

编写简单的单元测试

单元测试是一种测试函数和方法等小段代码的方法。 它的用途在于让您及早发现错误。 单元测试会让您的测试策略更高效,因为它们小且独立,易于维护。

我们来创建一个示例,练习一下测试。 创建函数 Fooer,它将 int 作为输入并返回 string。 如果输入的整数能被三整除,则返回 "Foo",否则,将数字作为 string 返回。

您可能意识到 FooBarQix 编码问题的一个过度简化的示例。 围绕这个问题编写测试有助于使用 Go 练习测试。

创建一个名为 fooer.go 的新文件,粘贴以下代码来创建示例:

package main

import "strconv"

// If the number is divisible by 3, write "Foo" otherwise, the number
func Fooer(input int) string {

    isfoo := (input % 3) == 0

    if isfoo {
        return "Foo"
    }

    return strconv.Itoa(input)
}

Go 中的单元测试与被测函数位于同一个软件包(即同一个文件夹)中。 按照惯例,如果函数在文件 fooer.go 中,那么该函数的单元测试则在文件 fooer_test.go 中。

编写一个简单的函数来测试 Fooer 函数,后者会在您输入 3 时返回 "Foo"

package main
	import "testing"
	func TestFooer(t *testing.T) {
		result := Fooer(3)
		if result != "Foo" {
		   t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo")
		}
	}

Go 中的测试函数以 Test 开头,并将 *testing.T 作为唯一形参。 在大多数情况下,单元测试会被命名为 Test[NameOfFunction]testing 软件包提供了与测试工作流交互的工具,例如 t.Errorf,它会通过在控制台上显示错误消息来指示测试失败。

您可以通过命令行运行测试:

go test

输出应如下所示:

在 GoLand 中,您可以点击间距中的绿色箭头运行特定测试

完成测试运行后,GoLand 会在 Run(运行)工具窗口中显示结果。 右侧控制台显示了当前测试会话的输出。 它会显示测试执行的详细信息以及测试失败或被忽略的原因。

要运行软件包中的所有测试,请点击顶部的绿色双三角形图标。

编写表驱动测试

编写测试时,您可能会为了覆盖所有用例而重复大量代码。 想一想您将如何覆盖 Fooer 示例中涉及的许多用例。 您可以为每种用例都编写一个测试函数,但这会导致大量重复。 您也可以在同一个测试函数中多次调用被测函数并逐次验证输出,但如果测试失败,则很难确定失败点。 最后,您还可以使用表驱动方式帮助减少重复。 顾名思义,这涉及将测试用例组织为包含输入和所需输出的表。

这有两个好处:

  • 表测试重用相同的断言逻辑,使测试保持整洁
  • 表测试可以清晰显示选择的输入和测试涵盖的内容。 此外,每一行都可以获得一个唯一名称,帮助识别正在测试的内容和表达测试的意图。

以下是 Fooer 函数的表驱动测试函数示例:

func TestFooerTableDriven(t *testing.T) {
	  // Defining the columns of the table
		var tests = []struct {
		name string
			input int
			want  string
		}{
			// the table itself
			{"9 should be Foo", 9, "Foo"},
			{"3 should be Foo", 3, "Foo"},
			{"1 is not Foo", 1, "1"},
			{"0 should be Foo", 0, "Foo"},
		}
	  // The execution loop
		for _, tt := range tests {
			t.Run(tt.name, func(t *testing.T) {
				ans := Fooer(tt.input)
				if ans != tt.want {
					t.Errorf("got %s, want %s", ans, tt.want)
				}
			})
		}
	}

表驱动测试从定义输入结构开始。 这相当于定义表的列。 表的每一行都列出了待执行的测试用例。 定义表后,您可以编写执行循环。

执行循环会调用定义子测试的 t.Run()。 因此,表的每一行都定义了一个名为 [NameOfTheFuction]/[NameOfTheSubTest] 的子测试。

这种测试编写方式非常流行,也被视作在 Go 中编写单元测试的规范方式。 GoLand 可以为您生成这些测试模板。 您可以右键点击函数,转到 Generate | Test for function(生成 | 函数测试)。 查看 GoLand 文档了解更多详细信息。 

您只需要添加测试用例:

		{"9 should be Foo", args{9}, "Foo"},
		{"3 should be Foo", args{3}, "Foo"},
		{"1 is not Foo", args{1}, "1"},
		{"0 should be Foo", args{0}, "Foo"},

执行测试时,您会看到 TestFooerTableDriven 函数运行四个子测试,每个对应表中的一行。

生成功能让表驱动测试的编写变得简单直观。

Testing 软件包

testing 软件包在 Go 测试中发挥着关键作用。 它让开发者能够使用不同类型的测试函数创建单元测试。 testing.T 类型提供了控制测试执行的方法,例如使用 Parallel() 并行运行测试,使用 Skip() 跳过测试,以及使用 Cleanup() 调用测试拆解函数。

错误和日志

testing.T 类型提供了多种与测试工作流交互的实用工具,包括 t.Errorf(),它会输出错误消息并将测试设为失败。

务必需要注意的是,t.Error* 不会停止测试的执行。 测试完成后,所有遇到的错误都将被报告。 有时,执行失败更有意义,在这种情况下,您应该使用 t.Fatal*。 测试执行期间,使用 Log*() 函数输出信息可能会很方便:

func TestFooer2(t *testing.T) {
			input := 3
			result := Fooer(3)
			t.Logf("The input was %d", input)
			if result != "Foo" {
				t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo")
			}
			t.Fatalf("Stop the test now, we have seen enough")
			t.Error("This won't be executed")
		}

输出提示应如下所示:

如图所示,最后一行 t.Error("This won't be executed") 已被跳过,因为 t.Fatalf 已经终止了此测试。

运行并行测试

测试默认按顺序运行,Parallel() 方法则指示测试应并行运行。 所有调用此函数的测试都将并行执行。 go test 通过以下方式处理并行测试:暂停各个调用 t.Parallel() 的测试,然后在所有非并行测试完成后将其并行恢复。 GOMAXPROCS 环境定义一次可以并行运行多少个测试,这个数字默认等于 CPU 的数量。

您可以构建一个并行运行两个子测试的小示例。 下面的代码将同时测试 Fooer(3)Fooer(7)

func TestFooerParallel(t *testing.T) {
		t.Run("Test 3 in Parallel", func(t *testing.T) {
			t.Parallel()
			result := Fooer(3)
			if result != "Foo" {
				t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo")
			}
		})
		t.Run("Test 7 in Parallel", func(t *testing.T) {
			t.Parallel()
			result := Fooer(7)
			if result != "7" {
				t.Errorf("Result was incorrect, got: %s, want: %s.", result, "7")
			}
		})
	}

GoLand 在执行期间会为各个测试输出所有状态信息(RUNPAUSECONT)。 运行上面的代码时,您可以清楚地看到 Test_3Test_7 开始运行之前被暂停。 在 Test_7 被暂停后,两个测试都将恢复运行,直到完成。

为了减少重复,您可能想在使用 Parallel() 时使用表驱动测试。 显然,这个示例需要复制某些断言逻辑。

跳过测试

使用 Skip() 方法可以将单元测试与集成测试分开。 集成测试同时验证多个函数和组件,执行起来通常较慢,因此有时更适合只执行单元测试。 例如,go test 接受旨在运行 “fast” 测试的 -test.short 标志。 然而,go test 并不决定测试是否为 “short”。 您需要结合使用 testing.Short()(使用 -short 时设为 true)和 t.Skip(),如下所示:

func TestFooerSkiped(t *testing.T) {
		if testing.Short() {
			t.Skip("skipping test in short mode.")
		}
		result := Fooer(3)
		if result != "Foo" {
			t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo")
		}
	}

运行 go test -v 时将执行此测试,但运行 go test -v -test.short 时测试将被跳过。

如下所示,测试在 short 模式下被跳过。

每次运行测试都编写标志可能很麻烦。 好在 GoLand 可为每个测试保存运行/调试配置。 每次使用间距中的绿色箭头运行测试都会创建一个配置。

您可以在 GoLand 的帮助中详细了解测试的配置模板。 

测试拆解和清理

Cleanup() 方法便于管理测试拆解。 首先,可以使用 defer 关键字时,为什么需要该函数可能并不明显。

按如下所示使用 defer 解决方案:

func Test_With_Cleanup(t *testing.T) {

  // Some test code

  defer cleanup()

  // More test code
}

虽然这很简单,但它也带来了一些问题,如这篇关于 Go 1.14 新功能的文章所述。 反对 defer 方式的主要论点是,它会使测试逻辑的设置更加复杂,并且在涉及许多组件时会使测试函数混乱。

Cleanup() 函数在每个测试(包括子测试)结束时执行,并清晰显示测试的预期行为。

func Test_With_Cleanup(t *testing.T) {

  // Some test code here

    t.Cleanup(func() {
        // cleanup logic
    })

  // more test code here

}

您可以阅读这篇文章中的示例详细了解测试清理。 

这时候就要提到 Helper() 方法了。 这个方法的用途是在测试失败时改进日志。 在日志中,helper 函数的行号会被忽略,只报告失败测试的行号,这有助于确定失败的测试。

func helper(t *testing.T) {
  t.Helper()
  // do something
}

func Test_With_Cleanup(t *testing.T) {

  // Some test code here

    helper(t)

  // more test code here

}

最后,TempDir() 是一种自动为测试创建临时目录并在测试完成时删除该文件夹的方法,无需编写额外清理逻辑。

func TestFooerTempDir(t *testing.T) {
    tmpDir := t.TempDir()

  // your tests
}

这个函数非常实用,但由于相对较新,许多 Go 开发者尚未了解,仍在测试中手动管理临时目录。

编写覆盖率测试

由于测试对现代开发工作至关重要,因此,了解测试覆盖了多少代码也是必不可少的环节。 您可以使用 Go 的内置工具为被测软件包生成测试报告:在测试命令中添加 -cover 标志即可:

go test -cover

请注意,您还可以添加标志 -v 获得更详细的日志。

默认情况下,测试覆盖率会计算通过测试覆盖的语句的百分比。 在输出中,您可以看到当前覆盖了 80% 的代码。 (测试没有涵盖 main 函数。) 测试覆盖率计算是一个复杂的过程,如果您想了解更多信息,可以阅读 The Cover Story

可以将多种实参传递给 go test -cover。 例如,go test 在覆盖率计算中只考虑带有测试文件的软件包。 您可以使用 -​coverpkg 将所有软件包添加到覆盖率计算中:

go test ./... -coverpkg=./...

您可以在这个 GitHub 仓库中找到一个有效示例,它详细展示了您需要 -​coverpkg 的原因。

使用标志 -coverprofile 将创建本地覆盖率报告文件。 这非常适合在 CI/CD 中运行测试,因为您会经常将报告发送到您喜爱的代码质量工具。

go test -coverprofile=output_filename

您还可以使用 go tool cover 格式化报告。 例如,-html 标志将打开默认浏览器来显示图形报告。

go tool cover -html=output_filename

您也可以使用 GoLand 的内置覆盖率报告。 如果您使用 Run with Coverage(使用覆盖率运行)选项,您将在侧面板上获得详细的覆盖率报告。

GoLand 还将提供高亮显示,覆盖的行为绿色,未覆盖的行为红色。 这对于决定后续要编写的测试非常有帮助。 在下图中,添加了一些额外代码,用于演示未被覆盖的代码的样子。

最后一个您需要了解的标志是 -covermode。 覆盖率默认根据语句覆盖率计算,但可以更改为考虑一条语句被覆盖的次数。 选项包括:

  • set:覆盖率基于语句。
  • count:计数是一条语句运行的次数。 它可以显示代码的哪些部分仅被轻微覆盖。
  • atomic:与计数类似,但用于并行测试。

在 CI/CD 中运行测试时,由于可以本地依赖 GoLand 获得直观的覆盖率报告,极其有必要了解运行覆盖率测试时使用的标志。 请注意,GoLand 默认使用 atomic 模式,在被覆盖语句左侧以绿色阴影表示覆盖率。 

编写基准测试

基准测试是一种测试代码性能的方式。 这些测试的目标是通过多次运行相同的函数来验证算法的运行时和内存使用情况。

要创建基准测试,必须满足以下条件:

  • 测试函数需要位于 *_test 文件中。
  • 函数名称必须以 Benchmark 开头。
  • 函数必须接受 *testing.B 作为唯一形参。
  • 测试函数必须包含一个 for 循环(以 b.N 为其上限)。

以下是 Fooer 函数基准测试的简单示例:

func BenchmarkFooer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fooer(i)
    }
}

目标代码应在基准函数中运行 N 次,N 会在运行时自动调整,直到每次迭代的执行时间在统计上稳定。

在这个示例中,基准测试运行了 59,969,790 次,速度为每次迭代 19 ns。 基准测试本身不会失败。

如果要在 CI/CD 中保存和分析结果,您必须使用其他工具。 perf/cmd 为此目的提供了软件包。 benchstat 可用于分析结果,benchsave 可用于保存结果。

编写模糊测试

模糊测试是一种强大的测试技术,使用随机输入探索错误或边缘用例。 Go 的模糊算法非常智能,它会通过生成许多新的输入组合来覆盖代码中尽可能多的语句。

要创建模糊测试,必须满足以下条件:

  • 测试函数需要位于 _test 文件中。
  • 函数名称必须以 Fuzz 开头。
  • 测试函数必须接受 testing.F 作为唯一形参。
  • 测试函数必须使用 f.Add() 方法定义初始值,即种子语料库
  • 测试函数必须定义模糊目标

我们来看一个综合示例:

func FuzzFooer(f *testing.F) {
    f.Add(3)
    f.Fuzz(func(t *testing.T, a int) {
        Fooer(a)
    })
}

模糊测试的目标不验证函数的输出,而是使用意外输入查找潜在边缘用例。 默认情况下,只要没有失败,模糊测试就会无限期地运行。 在 CI/CD 中,应该使用 -fuzztime 标志指定模糊测试运行的最长时间。 如果代码具有较多分支,这种测试方式特别有趣。即使使用表驱动测试,也很难覆盖大量输入,而模糊测试有助于解决这个问题。

要在 GoLand 中运行模糊测试,请点击间距中的绿色三角形,然后选择 Run | go test -fuzz(运行 | go test -fuzz)选项,否则(无 -fuzz)测试将使用语料库种子只运行一次。

Testify 软件包

Testify 测试框架提供了许多测试 Go 代码的工具。 至于应该使用 Testify 还是只使用标准库,在 Go 社区中存在相当多的争论。 支持者认为它增加了测试及其输出的可读性。

Testify 可以通过命令 go get github.com/stretchr/testify 安装。

Testify 提供断言函数和模拟,类似于传统测试框架,例如 Java 的 JUnit 或 NodeJS 的 Jasmine

func TestFooerWithTestify(t *testing.T) {

    // assert equality
    assert.Equal(t, "Foo", Fooer(0), "0 is divisible by 3, should return Foo")

    // assert inequality
    assert.NotEqual(t, "Foo", Fooer(1), "1 is not divisible by 3, should not return Foo")
}

Testify 提供了两个软件包:requireassert。 如果测试失败,require 软件包将停止执行,这有助于快速失败。 assert 可以收集信息,但会积累断言的结果。

func TestMapWithTestify(t *testing.T) {

    // require equality
    require.Equal(t, map[int]string{1: "1", 2: "2"}, map[int]string{1: "1", 2: "3"})

    // assert equality
    assert.Equal(t, map[int]string{1: "1", 2: "2"}, map[int]string{1: "1", 2: "2"})
}

运行上述测试时,将跳过第一个 require 断言下方的所有行。

输出日志也清楚表明了实际输出与预期输出之间的差异。 与 Go 的内置 testing 软件包相比,输出的可读性更高,特别是测试数据较为复杂的情况,例如长映射或复杂对象。 日志会准确指出不同的行,这有助于提高工作效率。

GoLand 与 Testify 集成来分析断言结果。

总结

测试的重要之处在于,它可以验证代码的逻辑并在更改代码时指出错误。 Go 提供了许多开箱即用的应用程序测试工具。 您可以使用标准库编写任何测试,Testify 及其“更好”的断言函数和模拟功能提供了可选的附加功能。

testing 软件包提供了三种测试模式:常规测试 (testing.T)、基准测试 (testing.B) 和模糊测试 (testing.F)。 任何类型测试的设置都非常简单。 testing 软件包还提供了许多辅助函数,它们有助于编写更好、更干净的测试。 在开始测试之前,先探索一下这个库。

最后,IDE 在高效编写测试方面发挥着至关重要的作用。 希望本文能帮助您了解 GoLand 的一些功能,例如生成表驱动测试、一键运行测试函数、覆盖率面板、颜色编码的覆盖率间距,以及便于调试的 Testify 集成。

本博文英文原作者:

Sue

Sergey Kozlovskiy

image description

Discover more