这是我参与「第五届青训营 」笔记创作活动的第1天
看课要想记得牢,还得笔记记得好
但是只是将ppt内容进行复述,感觉跟复读机和抄录员也没什么差别了...
so 就暂时不按照青训营要求的笔记模板去写了,就按着Golang测试部分自由发挥了
测试的重要性
其实现在对于一个普通学生来说,对测试的概念就是,我知道测试很重要,但是我这么个“小破程序”,还需要再特别写个测试嘛?,就这么一点逻辑,不会出bug的!(bushi)
因此,测试往往只是停留在意识上的,认为他很重要,但是觉得只是到后面进公司后,写大型的复杂的项目才会用到的,因此使用的甚少,了解与实践经历也十分缺乏。
于是我想从个人开发者日常开发中,测试在其中参与的角色,如何参与进开发的工作流中来展开。
面向“测试”编程
有一个说法叫做面向测试用例的编程思想
面向测试用例编程思想是一种软件设计和开发方法,指在编写代码之前先编写测试用例,并确保测试用例能够通过代码的测试。这种方法旨在确保代码能够符合预期功能,提高代码的可维护性和可测试性。
自我检讨,我基本写代码都是直接梭,刷刷的边想边写,全部写完,写个main函数大概调用试一下就算完事了
我们往往在学算法的过程中,都会去依靠测试用例,先去通过参考样例去推算程序应该怎样工作,再去考虑数据量与边界的问题,去根据各个用例综合考虑,再去完成算法题,当完成之后,还需要再过一遍数据点,判断一下是不是都能过。
根据一般情况,往往我们分析完,写完算法题,在提交时,还会出现很多数据点过不去的情况,更不要提在没有分析过测试用例,直接写代码的情况了,出现错误更是大概率时间。因此在开发的时候,重视测试用例,是一件很必要的事情。
- 保证质量: 通过测试用例, 可以确保代码能够符合预期功能, 提高代码的可靠性.
- 提高可维护性: 测试用例能够帮助程序员在重构代码时确保不会破坏已有功能.
- 简化调试: 通过测试用例, 可以快速定位并调试问题.
- 加速开发进度: 编写测试用例能够提前发现问题, 避免在后期测试阶段发现问题.
除了确保程序质量外,同时也是一种验证自己的模块或者函数设计十分优雅的办法,是否可以很容易的验证该模块/函数的正确性,耦合度高不高。同时也是演示本模块使用的方法的一个办法。
如何测试
测试分为哪些
测试一般分为,
-
回归测试一般是QA同学手动通过终端回归一些固定的主流程场景
-
集成测试是对系统功能维度做测试验证,
-
单元是在开发阶段,开发者对单独的函数、模块做功能验证
层级从上至下,测试成本逐渐减低,而测试覆盖率却逐步上升,所以单元测试的覆盖率一定程度上决定代码的质量
单元测试
单元测试主要包括,输入,测试单元,输出,以及校对
单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;
单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。 另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。
基本单元测试
测试约定规则
- 所有测试文件以
_test.go结尾 - 测试函数命名为
func TestXxxx(*testing.T)格式 - 初始化逻辑放到
TestMain中
通过以上规则,可以新增一些测试文件,编写相关测试函数
- 测试用例名称一般命名为
Test加上待测试的方法名。- 测试用的参数有且只有一个,在这里是
t *testing.T。
如何运行单元测试?
go test是Go语言内置的测试工具, 可以在命令行中运行测试用例.
go test [flags] [packages]
flags:
- -v 显示详细输出
- -run 指定运行的测试用例
- -count 指定测试的次数
- -cover 显示代码覆盖率
- -timeout 指定测试的超时时间
- ...
packages:
- 包名,可以指定多个包名,如果不指定,默认运行当前目录下所有测试用例。
单元测试-assert
单元测试中的assert是一种断言,用于验证代码的预期行为。在Go语言中,可以使用testing包中的函数来实现assert。
常用的assert函数有:
- t.Error(args ...interface{}): 当发现错误时输出错误信息
- t.Errorf(format string, args ...interface{}): 当发现错误时输出格式化的错误信息
- t.Fail(): 直接标记当前测试失败
- t.FailNow(): 直接标记当前测试失败并结束当前测试
- t.Fatal(args ...interface{}): 当发现错误时输出错误信息并结束当前测试
- t.Fatalf(format string, args ...interface{}): 当发现错误时输出格式化的错误信息并结束当前测试
func TestAdd(t *testing.T) {
result := add(1, 2)
if result != 3 {
t.Error("Expected 3, but got", result)
}
}
上面的代码中, 我们使用t.Error()函数来断言函数add(1,2)的返回值是否等于3, 如果不等于, 则输出错误信息.
为了方便测试,第三方库也提供了一些assert库,比如说"github.com/stretchr/testify/assert",这样的话,可以使用assert.Equal(t, expected, actual)来进行断言,可以省去手写if判断的过程。
单元测试-覆盖率
单元测试覆盖率是指单元测试中涵盖代码中每一行/每一块的比例。高的覆盖率表明测试用例足够充分,能够有效地检测代码中的错误。
Go语言中可以使用命令 go test -cover 来查看单元测试覆盖率。
例如:
go test -cover mypackage
这个命令会运行mypackage包下的所有测试用例,并显示测试覆盖率。
输出结果会类似这样:
PASS
coverage: 88.9% of statements
ok mypackage 0.012s
Tips
一般覆盖率:50%-60%,较高覆盖率80%(资金型服务)
测试分支相互独立,完全覆盖
测试单元力足够小,函数单一职责
子测试(Subtests)
Go语言中支持子测试(subtests),这样可以在一个测试函数中运行多个测试用例。子测试可以让你更好的组织和维护测试用例。
在某个测试用例中,根据测试场景调用
t.Run(name string, f func(*testing.T))
创建不同的子测试用例:
func TestAdd(t *testing.T) {
t.Run("Positive numbers", func(t *testing.T) {
result := add(1, 2)
if result != 3 {
t.Error("Expected 3, but got", result)
}
})
t.Run("Negative numbers", func(t *testing.T) {
result := add(-1, -2)
if result != -3 {
t.Error("Expected -3, but got", result)
}
})
}
上面的代码中,t.Run()函数被调用了两次,每次调用都会运行一个子测试。这样,我们可以在一个测试函数中分别测试两种不同的情况:正数和负数的情况。
注:本例没有使用t.Fatal/t.Fatalf而是使用t.Error/t.Errorf,区别在于后者遇错不停,还会继续执行其他的测试用例,前者遇错即停
对于多个子测试的场景,更推荐如下的写法(table-driven tests):
// calc_test.go
func TestMul(t *testing.T) {
cases := []struct {
Name string
A, B, Expected int
}{
{"pos", 2, 3, 6},
{"neg", 2, -3, -6},
{"zero", 2, 0, 0},
}
for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
if ans := Mul(c.A, c.B); ans != c.Expected {
t.Fatalf("%d * %d expected %d, but %d got",
c.A, c.B, c.Expected, ans)
}
})
}
}
使用切片来组织测试数据是一种很常见的做法,这样可以使用循环来创建子测试,这样可以使用相同的测试函数来测试多种不同的输入和期望输出。
这种方式的好处是:
- 新增用例非常简单,只需给cases切片新增一条测试数据即可。
- 测试代码可读性好,直观地能够看到每个子测试的参数和期待的返回值。
- 用例失败时,报错信息的格式比较统一,测试报告易于阅读。
- 如果数据量较大,或是一些二进制数据,推荐使用相对路径从文件中读取。这样可以使得代码更加简洁,并且也可以减少内存的使用。
单元测试-helper函数
帮助函数(helper functions)是一种在测试代码中使用的特殊函数,帮助函数主要用于简化测试代码,避免重复代码,提高代码可读性。
帮助函数还可以用于封装子测试的创建过程,使得测试用例更加简洁易读。
Go语言在1.9版本中引入了t.Helper()函数,用于标注帮助函数。当测试用例中出现错误时,调用t.Helper()的帮助函数会在输出错误信息时,显示调用该帮助函数的测试函数信息,而不是帮助函数内部信息。这样可以更好地定位和调试错误。
// calc_test.go
package main
import "testing"
type calcCase struct{ A, B, Expected int }
// 将创建子测试的逻辑抽取
func createMulTestCase(t *testing.T, c *calcCase) {
t.Helper()
if ans := Mul(c.A, c.B); ans != c.Expected {
t.Fatalf("%d * %d expected %d, but %d got",
c.A, c.B, c.Expected, ans)
}
}
func TestMul(t *testing.T) {
createMulTestCase(t, &calcCase{2, 3, 6})
createMulTestCase(t, &calcCase{2, -3, -6})
createMulTestCase(t, &calcCase{2, 0, 1}) // wrong case
}
如上案例,当createMulTestCase(t, &calcCase{2, 0, 1}) // wrong case该行测试用例出错后,直接在错误报告中,表示此行出现错误,而不是helper函数内的某行发生错误。报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。
关于帮助函数有以下两点建议:
- 不要返回错误,帮助函数内部直接使用
t.Error或t.Fatal来报告错误。这样可以使得测试用例的主逻辑更加简洁易读,不会因为过多的错误处理代码而影响可读性。 - 调用
t.Helper()来标记帮助函数,能让报错信息更准确,有助于定位错误。
setup 和 teardown
如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,那么就可以将这些逻辑提取到 setup 和 teardown 函数中。
例如, 执行前需要实例化待测试的对象, 如果这个对象比较复杂, 很适合将这一部分逻辑提取到setup函数中,这样就可以在每个测试用例中使用同一个实例,并且可以保证实例化的正确性。执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等
setup 和 teardown 是在测试运行前后执行的函数,分别用于初始化测试环境和清理测试环境。
-
setup 函数在测试用例执行前被调用,用于创建测试环境,如创建数据库连接、建立测试数据等。
-
teardown 函数在测试用例执行后被调用,用于清理测试环境,如关闭数据库连接、删除测试数据等。
func setup() {
fmt.Println("Before all tests")
}
func teardown() {
fmt.Println("After all tests")
}
func Test1(t *testing.T) {
fmt.Println("I'm test1")
}
func Test2(t *testing.T) {
fmt.Println("I'm test2")
}
func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}
在这个测试文件中,有两个测试用例,分别是 Test1 和 Test2。这些测试用例会在运行时被调用,并打印出相应的输出。
文件中还包含了 setup 和 teardown 两个函数,在测试用例执行前后被调用,在最初和最后的时候打印 "Before all tests" 和 "After all tests"。
还有一个 TestMain 函数,这个函数在运行测试时会被调用。TestMain 函数中的 m.Run() 会触发所有测试用例的执行,并使用 os.Exit() 处理返回的状态码,如果不为0,说明有用例失败。这样可以调用 m.Run() 前后做一些额外的准备(setup)和回收(teardown)工作。
因此可以在调用 m.Run() 前后做一些额外的准备(setup)和回收(teardown)工作。
单元测试-Mock
当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件I/O等。 这种场景就非常适合使用 mock/stub 测试。简单来说,就是用 mock 对象模拟依赖项的行为。
单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。
幂等是指每一次测试运行都应该产生与之前一样的结果
这里我们用了Monkey,monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值。
Mockey Patch 的作用域在 Runtime,在运行时通过通过 Go 的 unsafe 包,能够将内存中函数的地址替换为运行时函数的地址。,将待打桩函数或方法的实现跳转。
在此案例中,ProcessFirstLine函数依赖于ReadFirstLine函数,但是ReadFirstLine存在对文件的处理。因此测试用例的设定,与ReadFirstLine函数所读取的文件存在很强的依赖关系,但是本测试仅仅希望测试ProcessFirstLine函数的功能。
为了只能单纯测试ProcessFirstLine函数的功能,而避免ReadFirstLine函数会受到在不同环境下读取不同文件返回结果不同的影响,去保证测试case的稳定性,因此我们对读取文件函数进行mock,屏蔽对于文件的依赖。
通过patch对Readfineline进行打桩mock,默认返回line110,并通过defer卸载mock,这样整个测试函数就摆脱了本地文件的束缚和依赖。
Benchmark 基准测试
Go 语言还提供了基准测试框架。基准测试是指测试一段程序的运行性能及耗费 CPU 的程度,这样可以在开发过程中及早发现代码性能问题,并且可以帮助我们确定代码的运行速度,并在不同的环境中进行比较。使用基准测试有助于我们更好的定位性能瓶颈,提高代码的性能。
基准测试用例的定义如下:
func BenchmarkFunctionName(b *testing.B) {
// 测试代码
}
- 函数名必须以
Benchmark开头,后面一般跟待测试的函数名 - 参数为
b *testing.B。 - 执行基准测试时,需要添加
-bench参数。
$ go test -benchmem -bench .
...
BenchmarkHello-16 15991854 71.6 ns/op 5 B/op 1 allocs/op
...
基准测试报告是由 Go 语言中的 testing.B 结构体生成的。
type BenchmarkResult struct {
N int // 迭代次数
T time.Duration // 基准测试花费的时间
Bytes int64 // 一次迭代处理的字节数
MemAllocs uint64 // 总的分配内存的次数
MemBytes uint64 // 总的分配内存的字节数
}
这些列的值都是基于一定数量的迭代次数得出的,并且每一列的值都是对整体的统计,不是对单次迭代的统计。 对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。
如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer() 先重置定时器,例如:
func BenchmarkHello(b *testing.B) {
... // 耗时操作
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Sprintf("hello")
}
}
面向测试的开发流程
基于上面所了解的测试方法,已经逐渐有了一个面向测试的开发流程的思路。
首先,在开发一个功能的时候,先尽可能的去理清楚这个功能到底是什么,如何工作,依赖哪些模块。
这些信息,首先可以帮助我们对功能有个直观的认识,也可以搞清楚该功能对其他模块提供哪些服务,同时又依赖了哪些模块来工作。同时,知晓这些信息可以帮助我更好的设计出各种测试用例,通过测试用例的各个情况再去思考如何设计与开发。
同时,知晓该功能所依赖的模块,也可以帮助我在完成单元测试的时候,知道应该是否以及应该如何打桩mock。
最后,可以通过覆盖率来知晓当前编写的单元测试所覆盖功能代码的范围,并且使用基准测试了解本功能的性能如何,是否可以需要再进一步优化。