文章内容更新请以 WGrape GitHub博客 : 一文帮你解决单元测试中的所有疑问 为准
前言
本文原创,著作权归WGrape所有,未经授权,严禁转载
阅读指南
阅读此篇文章,你能从中学到什么 ?
- 什么是单元测试,为什么需要单元测试?
- 在Golang中如何使用单元测试
- 单元测试的常用场景
- 单元测试的通用规范
一、什么是单元测试
在项目流程中占据重要位置的测试流程,通常是指在开发流程结束后进行的一次规范化、系统化的全面测试,这个测试工作一般是由专门的测试人员(QA)完成的,它是产品上线前的最后一道安全保障。
但随着技术的发展,人们越来越意识到一个问题,测试人员虽然可以使用接口测试、测试覆盖率、自动化测试等各种更先进的测试方式,但本质上都只能从用户的使用角度去测试,而用户的使用行为是无法完全枚举出来的。这就意味着只依赖测试人员的测试,一定是会存在有Bug风险的。
所以为了进一步提高测试质量,就产生了一种从代码角度去测试的方法,它以函数作为基本的测试单元,并在输入特定的Case后,通过比较输出是否符合预期,来表示此单元是否通过测试,这就是单元测试。
二、单元测试的重要性
在一般的项目流程中,开发完成后,测试才会介入开始工作。这种流程下,经常会遇到开发质量较低,导致测试工作被阻塞甚至要求打回重新开发的问题。
由此产生了一种称为测试驱动开发(Test-Driven Development :TDD)的开发方式,它要求在开发项目的同时,也必须编写单元测试代码。单元测试作为整个测试驱动开发中的核心,旨在通过测试来提升代码质量,驱动开发过程。
三、遵守首要原则
1、AIR原则
在宏观上,AIR原则定义了单元测试的意义就像是空气一样,虽然在线上环境看不到它的存在,但它却是线上安全的必要保障
- Automatic(自动化):单元测试是项目的一种强制性约束,必须是完全自动化的,而且必须随着项目的进行而自动执行,否则就会失去单元测试的意义
- Independent(独立性):单元测试过程中必须使用断言去验证单元测试的正确性,不能有任何人为参与的过程,比如最常见的 Print打印操作都是错误的
- Repeatable(可重复):单元测试的整个生命周期都与项目周期同在,在期间会被执行无数次,为了保证单元测试的简单性和可维护性,各个测试单元之间不能存在耦合、互相调用、执行先后顺序的问题,以保证单元测试是可以被重复执行且无任何影响的
2、BCDE原则
在微观上,BCDE原则规定了如何编写一个合格的单元测试
- B: Border,指边界值测试,如特殊值、循环边界、时间边界等
- C: Correct,指输入正确的值,并得到预期的结果
- D: Design,指编写单元测试的过程,需要与开发设计文档相结合
- E: Error,指的是单元测试目标是证明程序有错,而不是证明程序无错
四、如何编写单元测试
1、创建TestMain
每一个Package下,都需要有一个TestMain函数,这个TestMain函数可以写在此Package下的任何 _test.go 文件中。通常在TestMain函数中会完成各种配置初始化相关的操作,并通过m.Run()
自动执行所有Test*
单元测试。
func TestMain(m *testing.M) {
// config初始化相关
gopath := os.Getenv("GOPATH")
if gopath == "" {
gopath = build.Default.GOPATH
}
cnofigFile := gopath + "/src/project/config/config.toml"
initConfig(cnofigFile)
// 一定要执行这个,否则会提示找不到测试
m.Run()
}
2、创建单元测试文件
创建一个以某个具体go文件名为前缀,_test.go为后缀的文件即表示创建了一个单元测试文件。如现在有一个relation.go文件,再创建一个对应的relation_test.go文件即表示定义了一个relation.go的单元测试文件。
3、创建单元测试函数
单元测试函数需要在单元测试文件中创建,单元测试函数的名称通常以Test为前缀,且必须使用大驼峰命名。
func TestCompare(t *testing.T){
}
4、单元测试结果
每个单元的测试结果都是通过或失败,当检测到实际结果与预期值不符合的时候,可以使用t.Fail()
来标识测试失败未通过。
func TestCompare(t *testing.T){
if Compare(10, 20) != -1 {
t.Fail()
}
}
5、使用断言
在实际进行单元测试的时候,为了判断实际值与预期值是否一致,可能需要写很多丑陋的if条件语句,出现很多冗余代码。为了解决这个问题,可以使用断言。
断言是单元测试中用于判断结果是否符合预期的一种高效工具,Go语言本身不提供断言库,如果需要可以使用testify(https://github.com/stretchr/testify)库
func TestCompare(t *testing.T){
assert := assert.New(t)
assert.NotEqual(-1, Compare(10, 20), "Compare error")
}
五、场景需求
1、测试顺序控制
虽然不建议测试有执行顺序的依赖,但有时候对于需要控制测试顺序的场景,一般可以定义TestAll
函数(函数名任意即可),充当协调工作,通过控制 t.Run
的顺序来控制单元测试的顺序。
package match
import (
"container/heap"
"fmt"
"testing"
)
var workHeap = &WorkHeap{}
func testLiveHeapPush(t *testing.T) {
heap.Push(workHeap, WorkHeapElement{
WorkId: "id-1",
Score: 10,
})
heap.Push(workHeap, WorkHeapElement{
WorkId: "id-2",
Score: 5,
})
}
func testLiveHeapLen(t *testing.T) {
if workHeap.Len() != 2 {
t.Fail()
}
}
func testLiveHeapPop(t *testing.T) {
fmt.Println(workHeap)
if heap.Pop(workHeap).(WorkHeapElement).WorkId != "id-1" {
t.Fail()
return
}
}
func TestAll(t *testing.T) {
t.Run("testLiveHeapPush", testLiveHeapPush)
t.Run("testLiveHeapLen", testLiveHeapLen)
t.Run("testLiveHeapPop", testLiveHeapPop)
}
2、CI/CD
一般可以在项目目录下创建test.sh
脚本,在此脚本中完成对所有单元测试的运行,并将test.sh
脚本集成在CI/CD环境中即可,可以参考ParseAOF项目。
3、测试覆盖率
由于达到100%测试覆盖率是比较困难的,特别是对于大工程下的每一个单元测试,所以一般不会对单元测试中的测试覆盖率做强制要求。对于测试覆盖率的使用可以参考go test coverage
六、通用规范
1、最小测试单元
在单元测试中,测试的单元越小越精简,测试的准确度也就越高。所以要尽量做到使用最小测试单元,满足不可分割性
(1) Bad
func TestCompare(t *testing.T){
var (
a = 1
b = 2
)
Swap(a, b)
if Compare(a, b) != true {
t.Fail()
}
}
(2) Good
func TestCompare(t *testing.T){
if Compare(1, 2) != true {
t.Fail()
}
}
2、不依赖外部资源
在函数中经常有读取文件、数据库、Redis等外部资源的场景,对于这种情况,一定要把数据读取和数据处理这两部分解耦,测试数据处理部分即可。
(1) Bad
func TestIsOldUser(t *testing.T){
uid := 18726
user := GetUserFromDB(uid)
if IsOldUser(user) != true {
t.Fail()
}
}
(2) Good
func TestIsOldUser(t *testing.T){
user := User{
age: 23,
}
if IsOldUser(user) != true {
t.Fail()
}
}