引入
正常的业务开发之后的测试流程,都是先单元测试,后集成测试。
单元测试:针对每一个方法进行的测试,单独验证每一个方法的正确性。 集成测试:多个组件合并在一起的测试,验证各个方法、组件之间配合无误。所以一般项目都是开发人员要先搞单元测试,单元测试初步验证之后,再集成测试。
单元测试验证了各个方法的基本逻辑之后,集成测试就比较少问题了。
一、单元测试基本介绍
1.1 什么是单元测试?
单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,是对软件中的最小可测试部分进行检查和验证。在面向对象编程中,最小测试单元通常是一个方法或函数。单元测试通常由开发者编写,用于验证代码的一个很小的、很具体的功能是否正确。单元测试是自动化测试的一部分,可以频繁地运行以检测代码的更改是否引入了新的错误。
特别是在一些频繁变动和多人合作开发的项目中尤为重要。你或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。
1.2 如何写好单元测试
首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock。
然后,写可测试的代码。高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。
1.3 单元测试的优点
单元测试讲究的是快速测试、快速修复。
测试该环节中的业务问题,比如说在写测试的时候,发现业务流程设计得不合理。 测试该环节中的技术问题,比如说nil之类的问题。单元测试,从理论上来说,你不能依赖任何第三方组件。也就是说,你不能使用MySQL或者Redis。
如图,要快速启动测试,快速发现BUG,快速修复,快速重测。
1.4 单元测试的设计原则
每个测试单元必须完全独立、能单独运行。 一个测试单元应只关注一个功能函数,证明它是正确的; 测试代码要能够快速执行。 不能为了单元测试而修改已完成的代码在编写代码后执行针对本次的单元测试,并执行之前的单元测试用例。 以保证你后来编写的代码不会破坏任何事情; 单元测试函数使用长的而且具有描述性的名字,例如都以test_开头,然后加上具体的函数名字或者功能描述;例如:func_test.go。 测试代码必须具有可读性。二、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命令表格:
更多可以参考 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.008s2.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.Error 或 t.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四、Table Driven 模式
4.1 介绍
Table Driven 模式是一种软件设计模式,它通过将测试数据存储在一个表格(通常是结构化的数据结构,如数组、切片、映射或结构体)中,然后在一个单独的函数或方法中遍历这个表格来执行测试。这种模式使得测试代码更加模块化、可读性和可维护性更高。
在 Go 语言中,Table Driven 模式通常通过定义一个结构体来组织测试数据,然后使用一个循环来遍历这个结构体,为每个测试用例执行相同的测试逻辑。这种方法可以很容易地添加新的测试用例,并且可以使测试代码更加简洁和易于维护。
4.2 Go 组织测试的方式
Go里面,惯常的组织测试的方式,都是用Table Driven。
Table Driven的形式如下图。主要分成三个部分:
测试用例的定义:即每一个测试用例需要有什么。 具体的测试用例:你设计的每一个测试用例都在这里。 执行测试用例:这里面还包括了对测试结果进行断言。注意,你要优先使用Table Driven,但是不用强求。
你把测试用例定义看做是列名,每一个测试用例就是一行数据,就能理解Table Driven这个含义了。
4.3 举个例子
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) } }) } }4.4 运行 Table Driven 下的单个测试
当你使用前面 Table Driven 的模式时,可以单个运行测试用例。
五、testify/assert 断言工具包
5.1 介绍
testify/assert 是一个流行的Go语言断言库,它提供了一组丰富的断言函数,用于简化测试代码的编写。这个库提供了一种更声明式的方式来编写测试,使得测试意图更加明确,代码更加简洁。
使用 testify/assert 时,您不再需要编写大量的 if 语句和 Error 方法调用来检查条件和记录错误。相反,您可以使用像 assert.Equal、assert.Nil、assert.True 这样的断言函数来验证测试的期望结果。
5.2 安装
go get github.com/stretchr/testify5.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 会自动标记测试为失败,并记录一个详细的错误消息。