青训营之如何进行单元测试 1

147 阅读5分钟

单元测试

测试是避免事故的最后一道屏障

测试分类

image.png

回归测试:一般回到终端层面进行软件测试

集成测试:对系统功能进行测试

单元测试:开发者对单独函数模块进行的测试

组成部分

image.png

  • 输入
  • 测试单元(比较宽泛)
  • 输出
  • 期望值

规则

  • 所有测试文件以 _test.go 结尾
  • func TestXxx(t *testing.T) 函数签名规范
  • 初始化逻辑放到 TestMain 中

代码实例

func HelloTom() string {
    return "Jerry"
}

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    
    if output != expectOutput {
        t.Errorf("Expected %s do not match actual %s", expectOutput, output)
    }
}
  1. 实际输出(output)
  2. 预期输出(expect)
  3. 比较预期输出和实际输出,如果不符合,使用
  • t.Fatal | t.Fatalf
  • t.Error | t.Errorf

打印错误日志,一般形式为

t.Errorf(expect %s but got %s, expect, output)

assert

import (
    "github.com/stretchr/tesetify/assert"
    "testing"
)

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, expectOutput, output)
}

func HelloTom() string {
    return "Tom"
}

这里调用第三方包中的 assert 函数来判断,期望值和实际输出值是否相同,solidity 语言中 assert(a > b, "error message") 和这个其实差不多,满足 a > b 条件就正常执行,否则就输出自定义的错误消息。

  1. 引入第三方库:github.com/stretchr/testify/assert
  2. 调用 assert.Equal(t, expect, output)
  3. 如果,结果不符合将自动打印错误日志

代码覆盖率

func JudgePassLine(score int16) bool {
    if score >= 60 {
        return true
    }
    
    return false
}

func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLine(70)
    assert.Equal(t, true, isPass)
}

测试 JudgePassLine 的代码覆盖率 (return false 是没有被覆盖的)

go test judgment_test.go judgement.go --cover

  1. 在单元测试成功的基础上,想要检测代码的覆盖率,可以使用 golang 测试自带的命令:go test xxx_test.go xxx.go --cover --cover 就表示输出代码的覆盖率;
  2. 可以通过可视化方式显示测试代码的覆盖率情况,下面会介绍。
  • 如何提升代码覆盖率
func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLineTrue(70)
    assert.Equal(t, true, isPass)
}

func TestJudgePassLineFalse(t *testing.T) {
    isPass := JudgePassLineFalse(50)
    assert.Equal(t, false, isPass)
}

增加了一个测试用例,覆盖了被测试代码的另一个分支逻辑;

image.png

依赖

测试过程中强依赖于 File、DB 或者 Cache 数据。单元测试应满足下面的特性:

幂等性:重复运行一个测试的 Case 时,每次的结果都是相同的

稳定性:单元测试是相互隔离的,可以在任何时间、任何函数独立运行

// firstline.go
package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func ReadFirstLine() string {
	open, err := os.Open("log")

	if err != nil {
		return ""
	}

	defer open.Close()

	scanner := bufio.NewScanner(open)

	for scanner.Scan() {
		// 返回scanner 读取到第一行数据
		return scanner.Text()
	}

	return ""
}

func ProcessFirstLine() string {
	line := ReadFirstLine()
	destLine := strings.ReplaceAll(line, "11", "00")

	return destLine
}

// firstline_test.go
func TestFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()

	assert.Equal(t, "line00", firstLine)
}

假设 log.txt 文件中的内容为一行 "line110";

  • ReadFirstLine 函数
  1. 打开指定文件 log,获取文件对象;
  2. 调用 bufio.NewScanner() 获取指定文件的输入流;
  3. 使用 scanner 的扫描器逐行读取输入流中的数据并返回给调用方一行数据后结束读取;如果输入流缓冲区没有额外的数据,返回空字符串。
  • ProcessFirstLine
  1. log 文件中读取一行数据;
  2. 将读取的数据中所有的子串 "11" 替换成 "00" 后返回给调用方。
  • TestFirstLine()
  1. 获取一行经过处理的数据;( expect = "line000" )
  2. 调用 assert.Equal() 将实际调用输出与期望输出对比,如果不相同则报错;

测试结果直接依赖于 log.txt 文件中的内容。

Mock 机制

如果上述的 log 文件被人篡改或者删除,那么单元测试就会失败,为了单元测试的稳定性,引入了 Mock 机制。

monkey: github.com/bouk/monkey

快速 Mock 函数

  • 为一个函数打桩

常用函数 Patch & Unpatch

// Patch replaces a function with another

type PatchGuard struct {
    target interface{} // 原函数
    replacement interface{} // 打桩函数
}

// 为函数打桩
func Patch(target, replacement interface{}) *PatchGuard {
    t := reflect.ValueOf(target)
    r := reflect.ValueOf(replacement)
    
    patchValue(t, r)
    
    return &PatchGuard{t, r} 
}

// 测试结束后,卸载桩
// Unpatch removes any monkey patches on target 
// returns whether target was patched in the first place
func Unpatch(target interface{}) bool {
    return unpatchValue(reflect.ValueOf(target))
}

代码实例: 对 ReadFirstLine 函数进行打桩测试,不再依赖本地文件

func TestProcessFirstLineWithMock(t *testing.T) {
    monkey.Patch(ReadFirstLine, func() string {
        return  "line110"
    })
    
    defer monkey.Unpatch(ReadFirstLine)
    line := ProcessFirstLine()
    assert.Equal(t, "line000", line)
}

测试函数执行流程介绍

  • 1、使用 monkey.Patch 为要被测试函数打桩(底层使用的反射实现,修改了调用函数的指针)
    • 函数签名 Patch(target, replacement)
    • 其中目标函数 target = ReadFirstLine (函数签名为 func() string)
    • 调用目标函数的实际输出为:"line110",因此我们构造一个与 ReadFirstLine 函数签名相同的匿名函数,它输出的结果和 ReadFirstLine 相同;
    • ReadFirstLine 成功执行返回的也是 line110,就相当于使用构造的匿名函数替代了原函数(ReadFirstLine),这样的话,即使 log 文件在测试之前被篡改或者被删除,也不会影响单元测试的结果,这就是打桩测试的意义。
  • 2、使用 defer 延迟执行打桩函数的卸载(恢复成指向原函数的地址)
  • 3、调用 ProcessFirstLine() (相当于调用的是匿名函数,返回 "line000")
  • 4、测试通过;
  • 5、函数真正退出之前,defer 延迟执行打桩函数卸载,函数底层指针重新指向原函数。

为什么不为 ProcessFirstLine 函数打桩?

因为它依赖函数 ReadFirstLine 函数的输出结果,而 ReadFirstLine 才是依赖 log 文件的函数,所以对 ReadFirstLine 函数打桩。(立地为牢)

  • 为一个方法打桩

打桩:使用 B 函数替换 A 函数,A 就是原函数,B 就是打桩函数

基准测试

bench.go

package main

import (
	"math/rand"
	"time"
)

var ServerIndex [10]int

func InitServerIndex() {
	for i := 0; i < 10; i++ {
		ServerIndex[i] = i + 100
	}
}

// 随机选择执行服务器
func Select() int {
        // 加上随机数种子
	rand.Seed(time.Now().UnixNano())
	return ServerIndex[rand.Intn(10)]
}

benckmark_test.go


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()
        }
    })
}

go test -bench=.

加随机种子

image.png

不加随机种子

image.png

引入 fastrand

package main

import "github.com/bytedance/gopkg/lang/fastrand"

var ServerIndex [10]int

func InitServerIndex() {
	for i := 0; i < 10; i++ {
		ServerIndex[i] = i + 100
	}
}

func FastSelect() int {
	return ServerIndex[fastrand.Intn(10)]
}

image.png

为什么并发的测试速度还要慢一点?

由于需要保证多线程的并发安全,所以需要使用并发安全锁,那么 goroutine 间就存在锁的竞争,没抢到锁的 goroutine 只能等待锁的释放,这就浪费了一些时间。