Go 单元测试基本介绍

一、单元测试基本介绍

1.1 什么是单元测试?

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,是对软件中的最小可测试部分进行检查和验证。在面向对象编程中,最小测试单元通常是一个方法或函数。单元测试通常由开发者编写,用于验证代码的一个很小的、很具体的功能是否正确。单元测试是自动化测试的一部分,可以频繁地运行以检测代码的更改是否引入了新的错误。

特别是在一些频繁变动和多人合作开发的项目中尤为重要。你或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。

1.2 如何写好单元测试

首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock

然后,写可测试的代码。高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。

1.3 单元测试的优点

单元测试讲究的是快速测试、快速修复。

  • 测试该环节中的业务问题,比如说在写测试的时候,发现业务流程设计得不合理。
  • 测试该环节中的技术问题,比如说nil之类的问题。

单元测试,从理论上来说,你不能依赖任何第三方组件。也就是说,你不能使用MySQL或者Redis

如图,要快速启动测试,快速发现BUG,快速修复,快速重测。

1.4 单元测试的设计原则

  1. 每个测试单元必须完全独立、能单独运行。
  2. 一个测试单元应只关注一个功能函数,证明它是正确的;
  3. 测试代码要能够快速执行。
  4. 不能为了单元测试而修改已完成的代码在编写代码后执行针对本次的单元测试,并执行之前的单元测试用例。
  5. 以保证你后来编写的代码不会破坏任何事情;
  6. 单元测试函数使用长的而且具有描述性的名字,例如都以test_开头,然后加上具体的函数名字或者功能描述;例如:func_test.go。
  7. 测试代码必须具有可读性。

二、Go语言测试

2.1 Go单元测试概要

Go 语言的单元测试默认采用官方自带的测试框架,通过引入 testing 包以及 执行 go test 命令来实现单元测试功能。

在源代码包目录内,所有以 _test.go 为后缀名的源文件会被 go test 认定为单元测试的文件,这些单元测试的文件不会包含在 go build 的源代码构建中,而是单独通过 go test 来编译并执行。

2.2 Go单元测试基本规范

Go 单元测试的基本规范如下:

  • 每个测试函数都必须导入 testing 包。测试函数的命名类似func TestName(t *testing.T),入参必须是 *testing.T
  • 测试函数的函数名必须以大写的 Test 开头,后面紧跟的函数名,要么是大写开关,要么就是下划线,比如 func TestName(t *testing.T) 或者 func Test_name(t *testing.T) 都是 ok 的, 但是 func Testname(t *testing.T)不会被检测到
  • 通常情况下,需要将测试文件和源代码放在同一个包内。一般测试文件的命名,都是 {source_filename}_test.go,比如我们的源代码文件是allen.go ,那么就会在 allen.go 的相同目录下,再建立一个 allen_test.go 的单元测试文件去测试 allen.go 文件里的相关方法。

当运行 go test 命令时,go test 会遍历所有的 *_test.go 中符合上述命名规则的函数,然后生成一个临时的 main 包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

2.3 一个简单例子

2.3.1 使用Goland 生成测试文件

我们来创建一个示例,创建名为 add.go的文件

package main

func Add(a int, b int) int {
	return a + b
}
func Mul(a int, b int) int {
	return a * b
}

这里借助Goland给 ADD 函数生成并且编写测试用例,只需要右键点击函数,转到Generate -> Test for file function(生成函数测试)。

Goland 为我们生成了add_test.go单测文件

package main

import "testing"

func TestAdd(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Add(tt.args.a, tt.args.b); got != tt.want {
				t.Errorf("Add() = %v, want %v", got, tt.want)
			}
		})
	}
}

2.3.2 运行单元测试

运行 go test,该 package 下所有的测试用例都会被执行。

go test .                                                
ok      gotest  1.060s

go test -v-v 参数会显示每个用例的测试结果,另外 -cover 参数可以查看覆盖率。

go test -v                                               
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      gotest  1.208s

2.3.3 完善测试用例

接着我们来完善上面的测试用例,代码如下:

package main

import "testing"

func TestAdd(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{
			name: "Adding positive numbers",
			args: args{a: 2, b: 3},
			want: 5,
		},
		{
			name: "Adding negative numbers",
			args: args{a: -2, b: -3},
			want: -5,
		},
		{
			name: "Adding positive and negative numbers",
			args: args{a: 2, b: -3},
			want: -1,
		},
		{
			name: "Adding zero",
			args: args{a: 2, b: 0},
			want: 2,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Add(tt.args.a, tt.args.b); got != tt.want {
				t.Errorf("Add() = %v, want %v", got, tt.want)
			}
		})
	}
}

2.3.5 回归测试

我们修改了代码之后仅仅执行那些失败的测试用例或新引入的测试用例是错误且危险的,正确的做法应该是完整运行所有的测试用例,保证不会因为修改代码而引入新的问题。

go test -v
=== RUN   TestAdd
=== RUN   TestAdd/Adding_positive_numbers
=== RUN   TestAdd/Adding_negative_numbers
=== RUN   TestAdd/Adding_positive_and_negative_numbers
=== RUN   TestAdd/Adding_zero
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/Adding_positive_numbers (0.00s)
    --- PASS: TestAdd/Adding_negative_numbers (0.00s)
    --- PASS: TestAdd/Adding_positive_and_negative_numbers (0.00s)
    --- PASS: TestAdd/Adding_zero (0.00s)
=== RUN   TestMul
=== RUN   TestMul/结果为0
=== RUN   TestMul/结果为-1
=== RUN   TestMul/结果为1
--- PASS: TestMul (0.00s)
    --- PASS: TestMul/结果为0 (0.00s)
    --- PASS: TestMul/结果为-1 (0.00s)
    --- PASS: TestMul/结果为1 (0.00s)
PASS
ok      gotest  0.912s

测试结果表明我们的单元测试全部通过。

2.4 Goland 直接运行单元测试

如果你的测试方法签名没错的话,就能看到这个绿色图标,点击就能看到很多选项。

最主要的是:

  • Run:运行模式,直接运行整个测试。
  • Debug:Debug模式,你可以打断点。
  • Run xxx with Coverage:运行并且输出测试覆盖率。
  • 其它Profile都是性能分析,很少用。

除非你要看测试覆盖率,不然都用Debug

2.5 Go Test 命令参数

go test 是 Go 语言的测试工具,你可以使用它来运行 Go 程序的测试函数。

你可以在命令行中使用以下参数来调用 go test 命令:

  • -run:指定要运行的测试函数的名称的正则表达式。例如,使用 go test -run TestAdd 可以运行名称为 TestSum 的测试函数。
  • -bench:指定要运行的基准测试的名称的正则表达式。例如,使用 go test -bench . 可以运行所有基准测试。
  • -count:指定要运行测试函数或基准测试的次数。例如,使用 go test -count 2 可以运行测试函数或基准测试两次。
  • -v:输出测试函数或基准测试的详细输出。
  • -timeout:设置测试函数或基准测试的超时时间。例如,使用 go test -timeout 1s 可以将超时时间设置为 1 秒。

以下是一个go Test命令表格:

参数 说明
-bench regexp 仅运行与正则表达式匹配的基准测试。默认不运行任何基准测试。使用 -bench .-bench= 来运行所有基准测试。
-benchtime t 运行每个基准测试足够多的迭代,以达到指定的时间 t(例如 -benchtime 1h30s)。默认为1秒(1s)。特殊语法 Nx 表示运行基准测试 N 次(例如 -benchtime 100x)。
-count n 运行每个测试、基准测试和模糊测试 n 次(默认为1次)。如果设置了 -cpu,则为每个 GOMAXPROCS 值运行 n 次。示例总是运行一次。-count 不适用于通过 -fuzz 匹配的模糊测试。
-cover 启用覆盖率分析。
-covermode set,count,atomic 设置覆盖率分析的 mode。默认为 "set",如果启用了 -race,则为 "atomic"。
-coverpkg pattern1,pattern2,pattern3 对匹配模式的包应用覆盖率分析。默认情况下,每个测试仅分析正在测试的包。
-cpu 1,2,4 指定一系列的 GOMAXPROCS 值,在这些值上执行测试、基准测试或模糊测试。默认为当前的 GOMAXPROCS 值。-cpu 不适用于通过 -fuzz 匹配的模糊测试。
-failfast 在第一个测试失败后不启动新的测试。
-fullpath 在错误消息中显示完整的文件名。
-fuzz regexp 运行与正则表达式匹配的模糊测试。当指定时,命令行参数必须精确匹配主模块中的一个包,并且正则表达式必须精确匹配该包中的一个模糊测试。
-fuzztime t 在模糊测试期间运行足够多的模糊目标迭代,以达到指定的时间 t(例如 -fuzztime 1h30s)。默认为永远运行。特殊语法 Nx 表示运行模糊目标 N 次(例如 -fuzztime 1000x)。
-fuzzminimizetime t 在每次最小化尝试期间运行足够多的模糊目标迭代,以达到指定的时间 t(例如 -fuzzminimizetime 30s)。默认为60秒。特殊语法 Nx 表示运行模糊目标 N 次(例如 -fuzzminimizetime 100x)。
-json 以 JSON 格式记录详细输出和测试结果。这以机器可读的格式呈现 -v 标志的相同信息。
-list regexp 列出与正则表达式匹配的测试、基准测试、模糊测试或示例。不会运行任何测试、基准测试、模糊测试或示例。
-parallel n 允许并行执行调用 t.Parallel 的测试函数,以及运行种子语料库时的模糊目标。此标志的值是同时运行的最大测试数。
-run regexp 仅运行与正则表达式匹配的测试、示例和模糊测试。
-short 告诉长时间运行的测试缩短其运行时间。默认情况下是关闭的,但在 all.bash 中设置,以便在安装 Go 树时可以运行健全性检查,但不花费时间运行详尽的测试。
-shuffle off,on,N 随机化测试和基准测试的执行顺序。默认情况下是关闭的。如果 -shuffle 设置为 on,则使用系统时钟种子随机化器。如果 -shuffle 设置为整数 N,则 N 将用作种子值。在这两种情况下,种子将报告以便复现。
-skip regexp 仅运行与正则表达式不匹配的测试、示例、模糊测试和基准测试。
-timeout d 如果测试二进制文件运行时间超过持续时间 d,则发生 panic。如果 d 为0,则禁用超时。默认为10分钟(10m)。
-v 详细输出:记录所有运行的测试。即使测试成功,也打印所有来自 LogLogf 调用的文本。
-vet list 配置在 "go test" 期间对 "go vet" 的调用,以使用由逗号分隔的 vet 检查列表。如果列表为空,"go test" 使用被认为总是值得解决的精选检查列表运行 "go vet"。如果列表为

更多可以参考 Go 语言的官方文档或使用 go help test 命令查看帮助信息

2.6 运行一个文件中的单个测试

如果只想运行其中的一个用例,例如 TestAdd,可以用 -run 参数指定,该参数支持通配符 *,和部分正则表达式,例如 ^$

go test -run TestAdd -v
=== RUN   TestAdd
=== RUN   TestAdd/Adding_positive_numbers
=== RUN   TestAdd/Adding_negative_numbers
=== RUN   TestAdd/Adding_positive_and_negative_numbers
=== RUN   TestAdd/Adding_zero
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/Adding_positive_numbers (0.00s)
    --- PASS: TestAdd/Adding_negative_numbers (0.00s)
    --- PASS: TestAdd/Adding_positive_and_negative_numbers (0.00s)
    --- PASS: TestAdd/Adding_zero (0.00s)
PASS
ok      gotest  1.008s

2.7 测试覆盖率

测试覆盖率是指代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。在公司内部一般会要求测试覆盖率达到80%左右。

Go提供内置功能来检查你的代码覆盖率,即使用go test -cover来查看测试覆盖率。

go test -cover
PASS
coverage: 100.0% of statements
ok      gotest  1.381s

还可以使用 -coverprofile 标志将覆盖率数据输出到一个文件中,然后使用 go tool cover 命令来查看更详细的覆盖率报告。

2.8 公共的帮助函数(helpers)

对一些重复的逻辑,抽取出来作为公共的帮助函数(helpers),可以增加测试代码的可读性和可维护性。 借助帮助函数,可以让测试用例的主逻辑看起来更清晰。

例如,我们可以将创建多次使用的逻辑抽取出来:

type addCase struct{ A, B, want int }

func createAddTestCase(t *testing.T, c *addCase) {
	// t.Helper()
	if ans := Add(c.A, c.B); ans != c.want {
		t.Fatalf("%d * %d expected %d, but %d got",
			c.A, c.B, c.want, ans)
	}

}

func TestAdd2(t *testing.T) {
	createAddTestCase(t, &addCase{1, 1, 2})
	createAddTestCase(t, &addCase{2, -3, -1})
	createAddTestCase(t, &addCase{0, -1, 0}) // wrong case
}

在这里,我们故意创建了一个错误的测试用例,运行 go test,用例失败,会报告错误发生的文件和行号信息:

go test
--- FAIL: TestAdd2 (0.00s)
    add_test.go:109: 0 * -1 expected 0, but -1 got
FAIL
exit status 1
FAIL    gotest  1.090s

可以看到,错误发生在第11行,也就是帮助函数 createAddTestCase 内部。116, 117, 118行都调用了该方法,我们第一时间并不能够确定是哪一行发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

修改 createAddTestCaseV1,调用 t.Helper()

type addCaseV1 struct {
	name string
	A, B int
	want int
}

func createAddTestCaseV1(c *addCaseV1, t *testing.T) {
	t.Helper()
	t.Run(c.name, func(t *testing.T) {
		if ans := Add(c.A, c.B); ans != c.want {
			t.Fatalf("%s: %d + %d expected %d, but %d got",
				c.name, c.A, c.B, c.want, ans)
		}
	})
}

func TestAddV1(t *testing.T) {
	createAddTestCaseV1(&addCaseV1{"case 1", 1, 1, 2}, t)
	createAddTestCaseV1(&addCaseV1{"case 2", 2, -3, -1}, t)
	createAddTestCaseV1(&addCaseV1{"case 3", 0, -1, 0}, t)
}

运行 go test,报错信息如下,可以非常清晰地知道,错误发生在第 131 行。

go test
--- FAIL: TestAddV1 (0.00s)
    --- FAIL: TestAddV1/case_3 (0.00s)
        add_test.go:131: case 3: 0 + -1 expected 0, but -1 got
FAIL
exit status 1
FAIL    gotest  0.434s

关于 helper 函数的 2 个建议:

  • 不要返回错误, 帮助函数内部直接使用 t.Errort.Fatal 即可,在用例主逻辑中不会因为太多的错误处理代码,影响可读性。
  • 调用 t.Helper() 让报错信息更准确,有助于定位。

当然,如果你是用Goland 编辑器的话,可以不使用t.Helper(),自动会帮你打印出错误详细信息

三、testing.T的拥有的方法

以下是提供的 *testing.T 类型的方法及其用途的注释:

// T 是 Go 语言测试框架中的一个结构体类型,它提供了用于编写测试的方法。
// 它通常通过测试函数的参数传递给测试函数。

// Cleanup 注册一个函数,该函数将在测试结束时执行,用于清理测试过程中创建的资源。
func (c *T) Cleanup(func())

// Error 记录一个错误信息,但不会立即停止测试的执行。
func (c *T) Error(args ...interface{})

// Errorf 根据 format 和 args 记录一个格式化的错误信息,但不会立即停止测试的执行。
func (c *T) Errorf(format string, args ...interface{})

// Fail 标记测试函数为失败,但不会停止当前测试的执行。
func (c *T) Fail()

// FailNow 标记测试函数为失败,并立即停止当前测试的执行。
func (c *T) FailNow()

// Failed 检查测试是否失败。
func (c *T) Failed() bool

// Fatal 记录一个错误信息,并立即停止测试的执行。
func (c *T) Fatal(args ...interface{})

// Fatalf 记录一个格式化的错误信息,并立即停止测试的执行。
func (c *T) Fatalf(format string, args ...interface{})

// Helper 标记当前函数为辅助函数,当测试失败时,辅助函数的文件名和行号将不会显示在错误消息中。
func (c *T) Helper()

// Log 记录一些信息,这些信息只有在启用详细日志(-v标志)时才会显示。
func (c *T) Log(args ...interface{})

// Logf 记录一些格式化的信息,这些信息只有在启用详细日志(-v标志)时才会显示。
func (c *T) Logf(format string, args ...interface{})

// Name 返回当前测试或基准测试的名称。
func (c *T) Name() string

// Skip 标记测试为跳过,并记录一个错误信息。
func (c *T) Skip(args ...interface{})

// SkipNow 标记测试为跳过,并立即停止当前测试的执行。
func (c *T) SkipNow()

// Skipf 标记测试为跳过,并记录一个格式化的错误信息。
func (c *T) Skipf(format string, args ...interface{})

// Skipped 检查测试是否被跳过。
func (c *T) Skipped() bool

// TempDir 返回一个临时目录的路径,该目录在测试结束时会被自动删除。
func (c *T) TempDir() string

四、表格驱动测试

4.1 介绍

表格驱动测试不是工具、包或其他任何东西,它只是编写更清晰测试的一种方式和视角。

编写好的测试并非易事,但在许多情况下,表格驱动测试可以涵盖很多方面:表格里的每一个条目都是一个完整的测试用例,包含输入和预期结果,有时还包含测试名称等附加信息,以使测试输出易于阅读。

使用表格驱动测试能够很方便的维护多个测试用例,避免在编写单元测试时频繁的复制粘贴。

表格驱动测试的步骤通常是定义一个测试用例表格,然后遍历表格,并使用t.Run对每个条目执行必要的测试。

4.2 举个例子

func TestMul(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{
			name: "结果为0",
			args: args{a: 2, b: 0},
			want: 0,
		},
		{
			name: "结果为-1",
			args: args{a: -1, b: 1},
			want: -1,
		},
		{
			name: "结果为1",
			args: args{a: -1, b: -1},
			want: 1,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Mul(tt.args.a, tt.args.b); got != tt.want {
				t.Errorf("Mul() = %v, want %v", got, tt.want)
			}
		})
	}
}

五、testify/assert 断言工具包

5.1 介绍

testify/assert 是一个流行的Go语言断言库,它提供了一组丰富的断言函数,用于简化测试代码的编写。这个库提供了一种更声明式的方式来编写测试,使得测试意图更加明确,代码更加简洁。

使用 testify/assert 时,您不再需要编写大量的 if 语句和 Error 方法调用来检查条件和记录错误。相反,您可以使用像 assert.Equalassert.Nilassert.True 这样的断言函数来验证测试的期望结果。

5.2 安装

go get github.com/stretchr/testify

5.3 使用

断言包提供了一些有用的方法,可以帮助您在Go语言中编写更好的测试代码。

  • 打印友好、易于阅读的失败描述
  • 允许编写可读性强的代码
  • 可以为每个断言添加可选的注释信息
    看看它的实际应用:
package yours
import (
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestSomething(t *testing.T) {
  // 断言相等
  assert.Equal(t, 123, 123, "它们应该相等")
  // 断言不等
  assert.NotEqual(t, 123, 456, "它们不应该相等")
  // 断言为nil(适用于错误处理)
  assert.Nil(t, object)
  // 断言不为nil(当你期望得到某个结果时使用)
  if assert.NotNil(t, object) {
    // 现在我们知道object不是nil,我们可以安全地进行
    // 进一步的断言而不会引起任何错误
    assert.Equal(t, "Something", object.Value)
  }
}

每个断言函数都接受 testing.T 对象作为第一个参数。这就是它如何通过正常的Go测试能力输出错误信息的方式。

每个断言函数都返回一个布尔值,指示断言是否成功。这对于在特定条件下继续进行进一步的断言非常有用。

当我们有多个断言语句时,还可以使用assert := assert.New(t)创建一个assert对象,它拥有前面所有的断言方法,只是不需要再传入Testing.T参数了。

package yours
import (
  "testing"
  "github.com/stretchr/testify/assert"
)
func TestSomething(t *testing.T) {
  assert := assert.New(t)
  // 断言相等
  assert.Equal(123, 123, "它们应该相等")
  // 断言不等
  assert.NotEqual(123, 456, "它们不应该相等")
  // 断言为nil(适用于错误处理)
  assert.Nil(object)
  // 断言不为nil(当你期望得到某个结果时使用)
  if assert.NotNil(object) {
    // 现在我们知道object不是nil,我们可以安全地进行
    // 进一步的断言而不会引起任何错误
    assert.Equal("Something", object.Value)
  }
}

在上面的示例中,assert.New(t) 创建了一个新的 assert 实例,然后您可以使用这个实例的方法来进行断言。如果断言失败,testify/assert 会自动标记测试为失败,并记录一个详细的错误消息。

六、单元测试代码模板

func Test_Function(t *testing.T) {
	testCases := []struct {
		name string //测试用例的名称
		args any    //测试用例的输入参数
		want string //期望的返回值
	}{
		// 测试用例,测试用例表格
		{},
		{},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			//具体的测试代码
		})
	}
}

七、参考文档

热门相关:我的末世基地车   鬼喊抓鬼   血战天下   首席的亿万老婆   极品仙医在都市