一文帮你解决单元测试中的所有疑问

Posted by WGrape的博客 on June 29, 2022

文章内容更新请以 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()
    }
}