前言
参加字节青训营的对单元测试的笔记。
测试这一块,对后端开发其实还是挺重要的,之前春招也被问到过。
一、单元测试概述
在Go语言中,单元测试使用gotest命令。
在包目录中,以_test.go结尾的源代码就是gotest的一部分。
_test.go有三种类型,分别是测试函数、基准函数、示例函数、mock测试等。
-
测试函数的函数名前缀是Test,目的是测试程序的逻辑性。
-
基准函数的函数名前缀是Benchmark,目的是测试程序的性能。
-
示例函数的函数名前缀是Example,目的是写一些示例文档。
-
mock测试主要借助于gostub、GoConvey、gomonkey等mock框架。目的是对依赖的数据进行解耦合,能够依托于自身条件进行测试(换而言之就是模拟出一个假的数据)。
总的来说我们测试的目的应是为了保证质量、提升效率。
二、测试函数
2.1、测试用例
先来看简单的两个测试:
import (
"github.com/stretchr/testify/assert"
"testing"
)
func HelloTom() string {
return "Tom"
}
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
func TestHelloJerry(t *testing.T) {
output := HelloTom()
expectOutput := "Jerry"
assert.Equal(t, expectOutput, output)
}
对其分别test运行:
可以看到测试结果分别是成功或者是失败,而失败也会有具体的错误信息与、预期结果。
2.2、检测覆盖率
为什么需要检测覆盖率?
- 覆盖率可以衡量代码是否经过了足够的测试
- 覆盖率可以评价项目的测试水准
- 覆盖率可以评估项目是否达到了高水准测试等级
接下来简单编写一个测试覆盖率的函数方法:
用于判断分数是否在60分及以上:
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
编写一个case进行测试:
func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t, false, isPass)
}
- 在终端输入:
go test judgment.go judgment_test.go --cover
进行测试:
覆盖率只达到了66.7%,因为case中的分数小于60,而没有大于等于60的情况。为了让覆盖率达到100%我们可以再加一个case。
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}
func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t, false, isPass)
}
此时覆盖率就变为100%了。
但是只是这样无法很高的提高覆盖率,因为只是体现的百分比。
接下来介绍一个可以可视化体现覆盖率的方法:
go test judgment.go judgment_test.go --cover -coverprofile=c.out
使用以上的命令生成覆盖率输出文件,再通过go tool生成可视化html结果。
go tool cover -html=c
可以很清晰的体现代码的测试覆盖情况。
测试函数tips
- 对于一般覆盖率: 50%~60%,较高覆盖率80%+。(例如支付等容错低的模块)
- 测试分支:应该做到相互独立、全面覆盖
- 测试粒度:测试单元的粒度要足够小,每个函数做到单一职责
三、基准测试
基准测试主要的目的就是为了测试已有函数,优化执行性能。
且go内置的测试框架,提供了进行基准测试的能力。
基本参数说明:
- -run 用于单次测试,一般用于代码逻辑验证
- -bench=. 执行所有 Benchmark,也可以通过用例函数名来指定部分测试用例
- -benchtime 指定测试执行时长
- -cpuprofile 输出 cpu 的 pprof 信息文件
- -memprofile 输出 heap 的 pprof 信息文件。
- -blockprofile 阻塞分析,记录 goroutine 阻塞等待同步(包括定时器通道)的位置
- -mutexprofile 互斥锁分析,报告互斥锁的竞争情况
其基本形式为:
func BenchmarkXXX(b *testing.B){
// ...
}
- 基准测试以Benchmark为前缀,需要一个*testing.B类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。
这里举一个服务器负载均衡的例子,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。
import "math/rand"
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10 ; i++ {
ServerIndex[i] = i+100
}
}
func Select() int {
return ServerIndex[rand.Intn(10)]
}
编写对应的测试函数:
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N ; i++ {
Select()
}
}
func BenchmarkSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next(){
Select()
}
})
}
分别进行测试查看结果:
- 基准测试以Benchmark开头,入参是testing.B,用b中的N值反复递增循环测试
(对一个测试用例的默认测试时间是1秒,当测试用例函数返回时还不到1秒,那么test中的N值将按1、2、5、10、20,、50.….递增,并以递增后的值重新进行用例函数测试。) - 而对于图中的20698326代表的是测试的执行次数,49.95代表的平均一次执行(op)花费了49.95的时间(ns)。
- ResetTimer为重置计时器,因为在测试主要流程前的准备工作,不应该作为基准测试的范围。 RunParallel为多协程并发测试,测试了这两个测试后,我们发现在并发的case下,性能存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
接下来使用字节的一个开源的fastrand进行优化:
import "github.com/bytedance/gopkg/lang/fastrand"
var ServerIndex [10]int
func FastSelect() int {
return ServerIndex[fastrand.Intn(10)]
}
再进行基准测试:
func BenchmarkFastSelectSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next(){
FastSelect()
}
})
}
可以发现这个速度算是快了三十多倍。主要的思路是牺牲了一定的数列一致性,再大多数场景是适用的,可以自己尝试一下。
四、mock测试
这次介绍主要用monkey框架进行打桩(mock)。
首先了解下什么是打桩?
- 桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果用函数B1来代替B,那么,B称为原函数,B1称为桩函数。打桩就是编写或生成桩代码。
monkey能够实现什么?
- 支持为一个函数打一个桩
- 支持为一个成员方法打一个桩
- 支持为一个接口打一个桩
- 支持为一个全局变量打一个桩
- 支持为一个函数变量打一个桩
- 支持为一个函数打一个特定的桩序列
- 支持为一个成员方法打一个特定的桩序列
- 支持为一个函数变量打一个特定的桩序列
- 支持为一个接口打一个特定的桩序列
- mock测试不但可以支持io类型的测试,比如:数据库,网络API请求,文件访问等。mock测试还可以做为未开发服务的模拟、服务压力测试支持、对未知复杂的服务进行模拟,比如开发阶段我们依赖的服务还没有开发好,那么就可以使用mock方法来模拟一个服务,模拟的这个服务接收的参数和返回的参数和规划设计的服务是一致的,那我们就可以直接使用这个模拟的服务来协助开发测试了;再比如要对服务进行压力测试,这个时候我们就要把服务依赖的网络,数据等服务进行模拟,不然得到的结果不纯粹。总结一下,有以下几种情况下使用mock会比较好:
- IO类型的,本地文件,数据库,网络API,RPC等
- 依赖的服务还没有开发好,这时候我们自己可以模拟一个服务,加快开发进度提升开发效率
- 压力性能测试的时候屏蔽外部依赖,专注测试本模块
- 依赖的内部函数非常复杂,要构造数据非常不方便,这也是一种
接下来进行一个简单的case进行测试:
import (
"bufio"
"os"
"strings"
)
func ReadFirstLine() string {
open, err := os.Open("log")
defer open.Close()
if err != nil {
return ""
}
scanner := bufio.NewScanner(open)
for scanner.Scan() {
return scanner.Text()
}
return ""
}
func ProcessFirstLine() string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
打开log文件将第1行的11换成00。并在如下编写不同测试函数(正常/mock)
import (
"bou.ke/monkey"
"github.com/stretchr/testify/assert"
"testing"
)
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
func TestProcessFirstLineWithMock(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}
自己可以去试试去把文件名改一改,发现两个如果把log名字改了第一个会发生IO错误,而进行了打桩的函数却不会。
说明成功对依赖的文件进行了解耦合。
五、总结
分别总结测试函数、Mock测试、基准测试的业务场景,与简单应用,虽然很多人不是专门的侧开岗,但是拥有一定的测试能力,对于代码的质量,以及查错的能力也会有一定的提升。