Go测试 | 青训营笔记

80 阅读9分钟

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

此笔记为课程与go圣经测试部分的综合笔记

go测试

Go语言的测试技术是相对低级的。它依赖一个go test测试命令和一组按照约定方式编写的测试函数,测试命令可以运行这些测试函数。编写相对轻量级的纯测试代码是有效的,而且它很容易延伸到基准测试和示例文档。

go test

测试规则

go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以_test.go为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test测试的一部分。

*_test.go文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。

  • 一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。
  • 基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准测试函数以计算一个平均的执行时间。
  • 示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。

go test命令会遍历所有的*_test.go文件中符合上述命名规则的函数,生成一个临时的main包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。

初始化可以放在TestMain函数中

func TestMain(m *testing.M) {
    //测试前:数据装在,配置初始化等前置工作
    _ = m.Run() //执行测试方法
    //测试后,释放资源等收尾工作
}

image-20230120184534839.png

测试命令

  • go test命令如果没有参数指定包那么将默认采用当前目录对应的包(和go build命令一样)。

  • 通过-v打印每个测试函数的名字与运行时间

  • 参数 -run 对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被 go test 测试命令运行

    #运行测试函数名中带French或Canal的测试用例
    F:\code\awesomeProject\word> go test -v -run="French|Canal"
    
  • 默认情况下不运行任何基准测试。我们需要通过-bench命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数。

    go test -bench=.
    
  • 还可以加上-benchmem命令行标志参数,此时将在报告中包含内存的分配数据统计。我们可以比较优化前后内存的分配情况:

    go test -bench=. -benchmem
    

测试函数

每个测试函数必须导入testing包。测试函数有如下的签名:

func TestXxx(t *testing.T) {
    // ...
}

测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:

func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }

测试函数案例

image-20230120021406416.png

让我们定义一个实例包word.go,其中只有一个函数IsPalindrome用于检查一个字符串是否从前向后和从后向前读都是一样的。

package word
​
// IsPalindrome检查一个字符串是否从前向后和从后向前读都是一样的
func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s)-1-i] {
            return false
        }
    }
    return true
}

在相同的目录下,word_test.go测试文件中包含了TestPalindrome和TestNonPalindrome两个测试函数。每一个都是测试IsPalindrome是否给出正确的结果,并使用t.Error报告失败信息:

package word
​
import "testing"
//测试字符串是否前后相同,输入:前后相同的字符串
func TestPalindrome(t *testing.T) {
    if !IsPalindrome("detartrated") {
        t.Error(`IsPalindrome("detartrated") = false`)
    }
    if !IsPalindrome("kayak") {
        t.Error(`IsPalindrome("kayak") = false`)
    }
}
//测试字符串是否前后相同,输入:前后不同的字符串
func TestNonPalindrome(t *testing.T) {
    if IsPalindrome("palindrome") {
        t.Error(`IsPalindrome("palindrome") = true`)
    }
}
//测试字符串是否前后相同,输入:法语
func TestFrenchPalindrome(t *testing.T) {
    if !IsPalindrome("été") {
        t.Error(`IsPalindrome("été") = false`)
    }
}
​
//测试字符串是否前后相同,输入:美国中部语言
func TestCanalPalindrome(t *testing.T) {
    input := "A man, a plan, a canal: Panama"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

go test命令如果没有参数指定包那么将默认采用当前目录对应的包(和go build命令一样)。

PS F:\code\awesomeProject\word> go test
--- FAIL: TestFrenchPalindrome (0.00s)          
    word_test.go:25: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)                                      
    word_test.go:33: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL                                                                       
exit status 1                         
FAIL    awesomeProject/word     0.055s

我们可以通过-v打印每个测试函数的名字与运行时间

PS F:\code\awesomeProject\word> go test -v
=== RUN   TestPalindrome
--- PASS: TestPalindrome (0.00s)                                           
=== RUN   TestNonPalindrome                                                
--- PASS: TestNonPalindrome (0.00s)                                        
=== RUN   TestFrenchPalindrome                                             
    word_test.go:25: IsPalindrome("été") = false                           
--- FAIL: TestFrenchPalindrome (0.00s)                                     
=== RUN   TestCanalPalindrome                                              
    word_test.go:33: IsPalindrome("A man, a plan, a canal: Panama") = false
--- FAIL: TestCanalPalindrome (0.00s)                                      
FAIL                                                                       
exit status 1 
FAIL    awesomeProject/word     0.056s

先写测试用例的另外的好处是,运行测试通常会比手工描述报告的处理更快,这让我们可以进行快速地迭代。如果测试集有很多运行缓慢的测试,我们可以通过只选择运行某些特定的测试来加快测试速度。

参数 -run 对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被 go test 测试命令运行

#运行测试函数名中带French或Canal的测试用例
PS F:\code\awesomeProject\word> go test -v -run="French|Canal"
=== RUN   TestFrenchPalindrome
    word_test.go:25: IsPalindrome("été") = false
--- FAIL: TestFrenchPalindrome (0.00s)
=== RUN   TestCanalPalindrome
    word_test.go:33: IsPalindrome("A man, a plan, a canal: Panama") = false
--- FAIL: TestCanalPalindrome (0.00s)
FAIL
exit status 1
FAIL    awesomeProject/word     0.054s

当然,一旦我们已经修复了失败的测试用例,在我们提交代码更新之前,我们应该以不带参数的 go test 命令运行全部的测试用例,以确保修复失败测试的同时没有引入新的问题。

运行发现有TestFrenchPalindromeTestCanalPalindrome用例错误,因此我们要修复这些错误。简要分析后发现第一个BUG的原因是我们采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正确处理。第二个BUG是因为没有忽略空格和字母的大小写导致的。

重写函数:

package word
​
import "unicode"///判断字符串是否前后相同
func IsPalindrome(s string) bool {
    var letters []rune
    for _, r := range s {
        if unicode.IsLetter(r) {
            letters = append(letters, unicode.ToLower(r))
        }
    }
    for i := range letters {
        if letters[i] != letters[len(letters)-1-i] {
            return false
        }
    }
    return true
}

同时我们也将之前的所有测试数据合并到了一个测试中的表格中。

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want  bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"detartrated", true},
        {"A man, a plan, a canal: Panama", true},
        {"Evil I did dwell; lewd did I live.", true},
        {"Able was I ere I saw Elba", true},
        {"été", true},
        {"Et se resservir, ivresse reste.", true},
        {"palindrome", false}, // non-palindrome
        {"desserts", false},   // semi-palindrome
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf("IsPalindrome(%q) = %v", test.input, got)
        }
    }
}

重新进行测试,测试通过

(base) PS F:\code\awesomeProject\word> go test -v                    
=== RUN   TestIsPalindrome
--- PASS: TestIsPalindrome (0.00s)
PASS
ok      awesomeProject/word     0.454

进行t.Errorf调用,会判断该测试函数失败,但该测试函数剩下的代码会继续进行,如果想失败后就直接停止执行该测试函数,则可以用t.Fatalt.Fatalf来替代t.Errorf停止当前测试函数,它们必须在和测试函数同一个goroutine内调用。

这种表格驱动的测试在Go语言中很常见。我们可以很容易地向表格添加新的测试数据,并且后面的测试逻辑也没有冗余,这样我们可以有更多的精力去完善错误信息。

随机测试

  • 表格驱动的测试:精心构造合适用例来测试函数的行为。费时间,但效果好
  • 随机测试:使用随机输入来测试探索函数的行为。节约时间,但效果不一定好

随机测试的例子

import "math/rand"//该函数返回一个随机字符串
func randomPalindrome(rng *rand.Rand) string {
    n := rng.Intn(25) // random length up to 24
    runes := make([]rune, n)
    for i := 0; i < (n+1)/2; i++ {
        r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
        runes[i] = r
        runes[n-1-i] = r
    }
    return string(runes)
}
​
func TestRandomPalindromes(t *testing.T) {
    // Initialize a pseudo-random number generator.
    seed := time.Now().UTC().UnixNano()
    t.Logf("Random seed: %d", seed)
    rng := rand.New(rand.NewSource(seed))
​
    for i := 0; i < 1000; i++ {
        p := randomPalindrome(rng)
        if !IsPalindrome(p) {
            t.Errorf("IsPalindrome(%q) = false", p)
        }
    }
}

虽然随机测试会有不确定因素,我们可以从失败测试的日志获取足够的信息。在我们的例子中,输入IsPalindrome的p参数将告诉我们真实的数据,但是对于函数将接受更复杂的输入,不需要保存所有的输入,只要日志中简单地记录随机数种子即可 。有了这些随机数初始化种子,我们可以很容易修改测试代码以重现失败的随机测试。

Mock进行测试

mock是在测试过程中,对于一些不容易构造/获取的对象,创建一个mock对象来模拟对象的行为。就是在运行时用我们定义的函数,替换运行时的某个函数,比如读取文件并打印字符串,为保证幂等性,防止别人修改文件导致数据对不上等问题,可以使用mock在运行时用我们自定义的函数来替换掉原始函数的读取文件获取字符串的函数

image-20230120210822659.png

  • mock.go
package test
​
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
}
​
  • mock_test.go
package test

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) {
	//这里就是指用定义的匿名函数,运行时替换掉ProcessFirstLine运行时所有的ReadFirstLine函数
	monkey.Patch(ReadFirstLine, func() string {
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	line := ProcessFirstLine()
	assert.Equal(t, "line000", line)
}
  • 测试
go test mock.go mock_test.go -v

不用mock实现同样替换效果

mock本质就是用我们的函数替换了原始函数进行执行,因此我们可以尝试要替换的函数用函数指针进行引用,这样测试的时候更改该函数指针指向的函数便好,因此可以实现如下思路

image-20230120230353902.png

  • storage.go
package storage

import (
	"fmt"
	"log"
	"net/smtp"
)

var usage = make(map[string]int64)

func bytesInUse(username string) int64 { return usage[username] }

const sender = "notifications@example.com"
const password = "correcthorsebatterystaple"
const hostname = "smtp.example.com"

const template = `Warning: you are using %d bytes of storage,
%d%% of your quota.`

//函数指针引用函数
var notifyUser = func(username, msg string) {
	auth := smtp.PlainAuth("", sender, password, hostname)
	err := smtp.SendMail(hostname+":587", auth, sender,
		[]string{username}, []byte(msg))
	if err != nil {
		log.Printf("smtp.SendMail(%s) failed: %s", username, err)
	}
}

func CheckQuota(username string) {
	used := bytesInUse(username)
	const quota = 1000000000 // 1GB
	percent := 100 * used / quota
	if percent < 90 {
		return // OK
	}
	msg := fmt.Sprintf(template, used, percent)
	notifyUser(username, msg)//使用函数指针调用函数
}
  • 测试
package storage

import "testing"

func TestCheckQuotaNotifiesUser(t *testing.T) {
	saved := notifyUser //保存原先函数指针的函数,等测试完,还要改回去
	defer func() { notifyUser = saved }()
	//将原先的函数指针指向空函数
	notifyUser = func(user, msg string) {
		
	}
}

这样便实现了运行时可以进行函数替换的效果

测试覆盖率

语句的覆盖率是指在测试中至少被运行一次的代码占总代码数的比例。

注意点

  • 一般覆盖率为50%-60%,较高覆盖率80%
  • 测试分支相互独立,全面覆盖
  • 测试单元粒度足够小,函数单一职责

案例

image-20230120184858859.png

image-20230120184830010.png

这里发现,代码覆盖率为66.7%,仔细观察可以判断测试函数只覆盖了功能函数关于soce>=60的代码,因此为了提高覆盖率我们可以增加一个关于score<60的测试函数

image-20230120185107799.png

基准测试

基准测试是测量一个程序在固定工作负载下的性能。

在Go语言中,基准测试函数和普通测试函数写法类似,但是以Benchmark为前缀名,并且带有一个*testing.B类型的参数;*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。

案例:随机选择服务器

  • load_balance_selector.go
package benchmark

import (
	"github.com/bytedance/gopkg/lang/fastrand"
	"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 FastSelect() int {
	return ServerIndex[fastrand.Intn(10)]
}
  • load_balance_selector_test.go
package benchmark

import (
   "testing"
)

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()
      }
   })
}
func BenchmarkFastSelectParallel(b *testing.B) {
  InitServerIndex() //这个函数初始化,不要放入实际时间统计
   b.ResetTimer()    //定时器重置
   b.RunParallel(func(pb *testing.PB) {
      for pb.Next() {
         FastSelect()
      }
   })
}

默认情况下不运行任何基准测试。我们需要通过-bench命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数。

go test -bench=.

image-20230120220418539.png

观察结果发现代码在并发测试情况下发生劣化,原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁

FastSelect里面的rand实现是其他公司开源的高性能随机数方法,主要思路牺牲了一定的数列一致性,在大多数场景是适用的

还可以加上-benchmem命令行标志参数,此时将在报告中包含内存的分配数据统计。我们可以比较优化前后内存的分配情况:

go test -bench=. -benchmem

image-20230120232155949.png

结果解读:

结果中基准测试名的数字后缀部分,这里是16,表示运行时对应的GOMAXPROCS的值,这对于一些与并发相关的基准测试是重要的信息。

  • 报告第2列表示该测试函数执行了多少次
  • 报告第3列表示该测试函数执行n次的平均时间
  • 报告第4列表示该测试函数平均每次操作分配多少内存
  • 报告第5列表示该测试函数进行了多少次内存分配操作

比较型基础测试

比较型的基准测试就是普通程序代码。它们通常是单参数的函数,由几个不同数量级的基准测试函数调用,就像这样:

func benchmark(b *testing.B, size int) { /* ... */ }
func Benchmark10(b *testing.B)         { benchmark(b, 10) }
func Benchmark100(b *testing.B)        { benchmark(b, 100) }
func Benchmark1000(b *testing.B)       { benchmark(b, 1000) }

通过函数参数来指定输入的大小,但是参数变量对于每个具体的基准测试都是固定的。