Golang工程实践之测试 | 青训营笔记

111 阅读14分钟

这是我参与「第五届青训营 」笔记创作活动的第1天

看课要想记得牢,还得笔记记得好

但是只是将ppt内容进行复述,感觉跟复读机和抄录员也没什么差别了...

so 就暂时不按照青训营要求的笔记模板去写了,就按着Golang测试部分自由发挥了

测试的重要性

其实现在对于一个普通学生来说,对测试的概念就是,我知道测试很重要,但是我这么个“小破程序”,还需要再特别写个测试嘛?,就这么一点逻辑,不会出bug的!(bushi)

因此,测试往往只是停留在意识上的,认为他很重要,但是觉得只是到后面进公司后,写大型的复杂的项目才会用到的,因此使用的甚少,了解与实践经历也十分缺乏。

于是我想从个人开发者日常开发中,测试在其中参与的角色,如何参与进开发的工作流中来展开。

面向“测试”编程

有一个说法叫做面向测试用例的编程思想

面向测试用例编程思想是一种软件设计和开发方法,指在编写代码之前先编写测试用例,并确保测试用例能够通过代码的测试。这种方法旨在确保代码能够符合预期功能,提高代码的可维护性和可测试性。

自我检讨,我基本写代码都是直接梭,刷刷的边想边写,全部写完,写个main函数大概调用试一下就算完事了

我们往往在学算法的过程中,都会去依靠测试用例,先去通过参考样例去推算程序应该怎样工作,再去考虑数据量与边界的问题,去根据各个用例综合考虑,再去完成算法题,当完成之后,还需要再过一遍数据点,判断一下是不是都能过。

根据一般情况,往往我们分析完,写完算法题,在提交时,还会出现很多数据点过不去的情况,更不要提在没有分析过测试用例,直接写代码的情况了,出现错误更是大概率时间。因此在开发的时候,重视测试用例,是一件很必要的事情。

  • 保证质量: 通过测试用例, 可以确保代码能够符合预期功能, 提高代码的可靠性.
  • 提高可维护性: 测试用例能够帮助程序员在重构代码时确保不会破坏已有功能.
  • 简化调试: 通过测试用例, 可以快速定位并调试问题.
  • 加速开发进度: 编写测试用例能够提前发现问题, 避免在后期测试阶段发现问题.

除了确保程序质量外,同时也是一种验证自己的模块或者函数设计十分优雅的办法,是否可以很容易的验证该模块/函数的正确性,耦合度高不高。同时也是演示本模块使用的方法的一个办法。

如何测试

测试分为哪些

测试一般分为,

  • 回归测试一般是QA同学手动通过终端回归一些固定的主流程场景

  • 集成测试是对系统功能维度做测试验证,

  • 单元是在开发阶段,开发者对单独的函数、模块做功能验证

层级从上至下,测试成本逐渐减低,而测试覆盖率却逐步上升,所以单元测试的覆盖率一定程度上决定代码的质量

单元测试

单元测试主要包括,输入,测试单元,输出,以及校对

image.png

单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;

单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。 另一方面可以提升效率,在代码有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, 如果不等于, 则输出错误信息.

image.png

为了方便测试,第三方库也提供了一些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函数内的某行发生错误。报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息

关于帮助函数有以下两点建议:

  1. 不要返回错误,帮助函数内部直接使用 t.Errort.Fatal 来报告错误。这样可以使得测试用例的主逻辑更加简洁易读,不会因为过多的错误处理代码而影响可读性。
  2. 调用 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。这些测试用例会在运行时被调用,并打印出相应的输出。

文件中还包含了 setupteardown 两个函数,在测试用例执行前后被调用,在最初和最后的时候打印 "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函数的功能。

image.png

为了只能单纯测试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。

最后,可以通过覆盖率来知晓当前编写的单元测试所覆盖功能代码的范围,并且使用基准测试了解本功能的性能如何,是否可以需要再进一步优化。