第八章:测试技巧

1 测试技巧:单元测试(Unit Test)

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。
写单元测试代码是一件短期没什么用,但却能长期收益的事情,特别是在人比较多的大团队里。
很多初级开发者不愿意花时间写测试代码,因为写测试代码比功能代码少了一些创造性,没有个人成就感,况且迭代快、排期紧导致没有时间去安排写单元测试。
在以下这些场景中,没有养成写单元测试习惯的话,就是一个灾难

  • 同事修改了某个之前由你编写的函数,但由于同事对这块函数理解上的不足,影响了某个异常场景的处理,你的同事没有测试到,把 bug 流到线上去
  • 某个函数的逻辑比较复杂,该函数的改动也很频繁,每一次的改过都要测试非常多的场景,费时费力

1.1 如何写单元测试

在开始之前,先初始化项目

go mod init github.com/iswbm/fuzz

然后在该项目中添加 main.go,内容如下

package main

import "fmt"

func Reverse(s string) string {
    b := [] byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev := Reverse(input)
    doubleRev := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q\n", rev)
    fmt.Printf("reversed again: %q\n", doubleRev)
}

现在我们要为 Reverse 函数编写单元测试代码,放在 reverse_test.go,Test 函数如下

  • 给定了三组数据
  • 遍历这几组数据,将 tc.in 做为 Reverses 函数的入参执行函数,其返回值跟预期的 tc.want 做对比
  • 若不相等,则测试不通过~
package main

import (
    "testing"
)

func TestReverse(t *testing.T) {
    testcases := []struct {
        in, want string
    }{
        {"Hello, world", "dlrow ,olleH"},
        {" ", " "},
        {"!12345", "54321!"},
    }
    for _, tc := range testcases {
        rev := Reverse(tc.in)
        if rev != tc.want {
                t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
}

对于单元测试函数来说,它的编写有一些格式,需要提一下,不然上面的函数,你可能会有疑问:

  • 单元测试,要导入 testing 包
  • 承载测试用例的测试文件,固定以 xxx_test.go(xxx 是原文件名)
  • 测试用例函数名称一般命名为 Test 加上待测试的方法名。
  • 测试用例函数的参数有且只有一个,在这里是 t *testing.T

1.2 执行测试用例

现在我们执行 go test 即是普通的单元测试,即执行该 package 下的所有函数的测试用例,输出 PASS 说明单元测试通过
image.png
要是加一个 -v 就可以查看显示每个测试用例的测试结果
image.png

1.3 子测试用例

如果有很多测试用例,可以用 -run 指定某个某个测试用例
image.png
若一个测试用例还可以分为多个子测试用例,比如下边的测试用例分为 foo 和 bar 两个子测试用例

package main

import (
    "testing"
)

func TestReverse(t *testing.T) {
    t.Run("foo", func(t *testing.T) {
        testcases := []struct {
            in, want string
        }{
            {"Hello, foo", "oof ,olleH"},
        }
        for _, tc := range testcases {
            rev := Reverse(tc.in)
            if rev != tc.want {
                    t.Errorf("[foo test]Reverse: %q, want %q", rev, tc.want)
            }
        }
    })

    t.Run("bar", func(t *testing.T) {
        testcases := []struct {
            in, want string
        }{
            {"Hello, bar", "rab ,olleH"},
        }
        for _, tc := range testcases {
            rev := Reverse(tc.in)
            if rev != tc.want {
                    t.Errorf("[bar test] Reverse: %q, want %q", rev, tc.want)
            }
        }
    })
}

使用 -run 主用例/子用例 就可以执行对应的子用例
image.png

2 测试技巧:模糊测试(Fuzzing)

2.1 什么是模糊测试?

单元测试,需要开发者根据函数逻辑,给定几组输入(入参)与输出(返回)的数据,然后 go test 根据这些数据集,调用函数,若返回值与预期相符,则说明函数的单元测试通过。
但单元测试的代码,也是由开发者写的一段一段代码,只要是代码,就会有 BUG,就会有遗漏的场景。
因此即使单元测试通过,也不代表你的程序没有问题。
可见,测试场景的数据集对于测试有多重要,而 Fuzzing 模糊测试就是一种用机器根据已知数据源,来自动生成测试数据的一种方案。

2.2 简单的示例

接着前一篇文章的例子,我们再往 reverse_test.go 中加入 Fuzzing 模糊测试的代码

// 记得前面导入 "unicode/utf8" 包

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

Fuzzing 模糊测试的代码格式与单元测试很像:

  • 函数名固定以 Fuzz 开头(单元测试是以 Test 开头)
  • 函数固定以 _testing.F 类型做为入参(单元测试是以_testing.T)

不一样的是 Fuzzing 模糊测试,提供两个函数:

  • t.Add:用于开发者输入模糊测试的种子数据,fuzzing 根据这些种子数据,自动随机生成更多测试数据
  • t.Fuzz:开始运行模糊测试,t.Fuzz 的入参是一个 Fuzz Target 函数(官方这么叫的),这个 Fuzz Target 函数的编写逻辑跟单元测试就一样了

在本例子中,Fuzz Target 接收 类型为 string 的入参,做为 Reverse 的输入源,然后利用两次 Reverse 的结果应与原字符串相等的原理进行测试。
有了 FuzzReverse 函数后,就可以使用如下命令进行模糊测试

go18 test -fuzz=Fuzz

通过输出发现测试并不顺利,Go 1.18 的 Fuzzing 会将导致测试异常的数据文件记录下来,使用 cat 可以查看该测试数据
image.png
记录下来后,该数据就可做为普通单元测试的数据,此时我们再执行 go test 就会引用该数据,当然了,在问题解决之前, go test 会一直报错
image.png

2.3 问题排查与解决

模糊测试帮我们发现了一个出乎意料的 Bug 场景:在中文里的字符 泃其实是由3个字节组成的,如果按照字节反转,反转后得到的就是一个无效的字符串。
因此为了保证字符串反转后得到的仍然是一个有效的UTF-8编码的字符串,我们要按照rune进行字符串反转。
为了更好地方便大家理解中文里的字符 泃按照rune为维度有多少个rune,以及按照byte反转后得到的结果长什么样,我们对代码做一些修改。
image.png
改完之后,再次执行 go test 就会提示测试成功,说明我们已经修复上面的那个场景的 BUG
image.png
当下我们已经发现并修复了一个 BUG,程序肯定还有更多 BUG 存在,要继续寻找可以再次进行模糊测试,重复上面的步骤即可,这里不再赘述。

2.4 更多参数介绍

在支持了 Fuzzing 模糊测试后,go test 工具也有了一些新的命令,在这里一并记录下
进行模糊测试

go test -fuzz=Fuzz

只对某个函数进行模糊测试:使用 -run=Fuzzxxx 或者 -fuzz=Fuzzxxx 指定模糊测试函数,避免执行到其他测试函数

go18 test -run=FuzzReverse
go18 test -fuzz=FuzzReverse

测试某个失败数据:使用 -run=file 指定数据文件

go test -run=FuzzReverse/1fdd0160e6b3dd8f1e6b7a4179b4787e0c014cf9c46c67a863d71e3a0277c213

指定模糊测试的时间:使用 -fuzztime 指定模糊测试时间或者迭代次数(默认无限期),避免一直在跑测试无法退出
还有一个 -fuzzminimizetime 参数,看官方文档的介绍,我没明白其作用,有知道的还请评论区分享下

go test -fuzz=Fuzz -fuzztime 30s

设置模糊测试进程数据:默认值是 $GOMAXPROCS,可根据实际情况进行设置,避免太占用机器的资源

go test -fuzz=Fuzz -parallel 4

2.5 写在最后

模糊测试的存在,并不是为了替代原单元测试,而是为单元测试提供更好的保障,是一个补充方案,而非替代方案。
单元测试的局限性在于,你只能用预期的输入进行测试;模糊测试在发现暴露出奇怪行为的意外输入方面非常出色。一个好的模糊测试系统也会对被测试的代码进行分析,因此它可以有效地产生输入,从而扩大代码覆盖面。
同时模糊测试的适用场景也比较有限,如果函数的入参并不是像本例中的那样的简单(字符串),而是各种对象呢?可能它就无能为力了吧。

3 测试技巧:网络测试

3.1 TCP/HTTP

假设需要测试某个 API 接口的 handler 能够正常工作,例如 helloHandler

func helloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello world"))
}

那我们可以创建真实的网络连接进行测试:

// test code
import (
    "io/ioutil"
    "net"
    "net/http"
    "testing"
)

func handleError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatal("failed", err)
    }
}

func TestConn(t *testing.T) {
    ln, err := net.Listen("tcp", "127.0.0.1:0")
    handleError(t, err)
    defer ln.Close()

    http.HandleFunc("/hello", helloHandler)
    go http.Serve(ln, nil)

    resp, err := http.Get("http://" + ln.Addr().String() + "/hello")
    handleError(t, err)

    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    handleError(t, err)

    if string(body) != "hello world" {
        t.Fatal("expected hello world, but got", string(body))
    }
}
  • net.Listen("tcp", "127.0.0.1:0"):监听一个未被占用的端口,并返回 Listener。
  • 调用 http.Serve(ln, nil) 启动 http 服务。
  • 使用 http.Get 发起一个 Get 请求,检查返回值是否正确。
  • 尽量不对 http 和 net 库使用 mock,这样可以覆盖较为真实的场景。

3.2 httptest

针对 http 开发的场景,使用标准库 net/http/httptest 进行测试更为高效。
上述的测试用例改写如下:

// test code
import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestConn(t *testing.T) {
    req := httptest.NewRequest("GET", "http://example.com/foo", nil)
    w := httptest.NewRecorder()
    helloHandler(w, req)
    bytes, _ := ioutil.ReadAll(w.Result().Body)

    if string(bytes) != "hello world" {
        t.Fatal("expected hello world, but got", string(bytes))
    }
}

使用 httptest 模拟请求对象(req)和响应对象(w),达到了相同的目的。

4 测试技巧:基准测试(Benchmark)

性能测试,也叫基准测试。
本文以下面这个 斐波那契 函数为例

package main

func Fib(n int) int {
    if n == 0 || n == 1 {
        return n
    }
    return fib(n-2) + fib(n-1)
}

4.1 编写基准测试函数

编写下基准测试函数

package main

import "testing"

func BenchmarkFib(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(30) // run fib(30) b.N times
    }
}

基准测试函数的编写,要遵循以下几点要求

  • 基准测试函数 统一以 Benchmark 开头,再加被测试的原函数名
  • 基准测试函数 的参数固定为 *testing.B

4.2 运行基准测试

那如何执行基准测试呢?
之前执行单元测试是执行 go test,而基准测试则在 go test 的基础上再加 -bench

# 必须加 .
go test -bench .

# 等同于
go test -bench github.com/iswbm/test

若想指定某个基准测试用例,可以在 -bench 后加函数,函数名还支持正则匹配

# 可以加 . 也可以不加
go test -bench="BenchmarkFib"

# 支持正则匹配
go test -bench="Fib$"

同时,你还可以指定 -cpu 参数,来告诉 go test 要给它分配几个 cpu 核心来执行测试

  • 默认情况下,-cpu 会自动取 GOMAXPROCS
  • 若你指定给它一数值,则代表它可以使用几个 cpu 核心
go test -bench -cpu=2
  • 若你指定给它一个列表,则代表它可以使用哪几个 cpu 核心
go test -bench -cpu=2,4,6

本例的 斐波那契 函数是 cpu 计算型的函数,由于它是串行的,因此指定多少个核心数结果都差不多,对于那些并发的函数就不一样啦

4.3 控制测试的时长

对于性能测试来说,提升测试准确度的一个重要手段就是增加测试的次数。我们可以使用 -benchtime 和 -count 两个参数达到这个目的。
benchmark 的默认时间是 1s,那么我们可以使用 -benchtime 指定为 5s。例如:

$ go test -bench='Fib$' -benchtime=5s .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8              1033           5769818 ns/op
PASS
ok      example 6.554s

实际执行的时间是 6.5s,比 benchtime 的 5s 要长,测试用例编译、执行、销毁等是需要时间的。
将 -benchtime 设置为 5s,用例执行次数也变成了原来的 5倍,每次函数调用时间仍为 0.6s,几乎没有变化。
-benchtime 的值除了是时间外,还可以是具体的次数。例如,执行 30 次可以用 -benchtime=30x:

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50           6121066 ns/op
PASS
ok      example 0.319s

调用 50 次 fib(30),仅花费了 0.319s。
-count 参数可以用来设置 benchmark 的轮数。例如,进行 3 轮 benchmark。

$ go test -bench='Fib$' -benchtime=5s -count=3 .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8               975           5946624 ns/op
BenchmarkFib-8              1023           5820582 ns/op
BenchmarkFib-8               961           6096816 ns/op
PASS
ok      example 19.463s

4.4 分析内存分配情况

-benchmem 参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。
在下面的例子中,generateWithCap 和 generate 的作用是一致的,生成一组长度为 n 的随机序列。唯一的不同在于,generateWithCap 创建切片时,将切片的容量(capacity)设置为 n,这样切片就会一次性申请 n 个整数所需的内存。

// generate_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generateWithCap(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0, n)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func generate(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func BenchmarkGenerateWithCap(b *testing.B) {
    for n := 0; n < b.N; n++ {
        generateWithCap(1000000)
    }
}

func BenchmarkGenerate(b *testing.B) {
    for n := 0; n < b.N; n++ {
        generate(1000000)
    }
}

运行该用例的结果是:

$ go test -bench='Generate' .
goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerateWithCap-8            44          24294582 ns/op
BenchmarkGenerate-8                   34          30342763 ns/op
PASS
ok      example 2.171s

可以看到生成 100w 个数字的随机序列,GenerateWithCap 的耗时比 Generate 少 20%。
我们可以使用 -benchmem 参数看到内存分配的情况:

goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerateWithCap-8  43  24335658 ns/op  8003641 B/op    1 allocs/op
BenchmarkGenerate-8         33  30403687 ns/op  45188395 B/op  40 allocs/op
PASS
ok      example 2.121s

Generate 分配的内存是 GenerateWithCap 的 6 倍,设置了切片容量,内存只分配一次,而不设置切片容量,内存分配了 40 次。

4.5 封装测试函数

不同的函数复杂度不同,O(1),O(n),O(n^2) 等,利用 benchmark 验证复杂度一个简单的方式,是构造不同的输入。对刚才的 benchmark 稍作改造,便能够达到目的。

// generate_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generate(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}
func benchmarkGenerate(i int, b *testing.B) {
    for n := 0; n < b.N; n++ {
        generate(i)
    }
}

func BenchmarkGenerate1000(b *testing.B)    { benchmarkGenerate(1000, b) }
func BenchmarkGenerate10000(b *testing.B)   { benchmarkGenerate(10000, b) }
func BenchmarkGenerate100000(b *testing.B)  { benchmarkGenerate(100000, b) }
func BenchmarkGenerate1000000(b *testing.B) { benchmarkGenerate(1000000, b) }

这里,我们实现一个辅助函数 benchmarkGenerate 允许传入参数 i,并构造了 4 个不同输入的 benchmark 用例。运行结果如下:

$ go test -bench .
goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerate1000-8            34048             34643 ns/op
BenchmarkGenerate10000-8            4070            295642 ns/op
BenchmarkGenerate100000-8            403           3230415 ns/op
BenchmarkGenerate1000000-8            39          32083701 ns/op
PASS
ok      example 6.597s

通过测试结果可以发现,输入变为原来的 10 倍,函数每次调用的时长也差不多是原来的 10 倍,这说明复杂度是线性的。

4.6 优化基准测试

1 ResetTimer

如果在 benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。比如下面的例子:

func BenchmarkFib(b *testing.B) {
    time.Sleep(time.Second * 3) // 模拟耗时准备任务
    for n := 0; n < b.N; n++ {
        fib(30) // run fib(30) b.N times
    }
}

运行结果是:

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50          65912552 ns/op
PASS
ok      example 6.319s

50次调用,每次调用约 0.66s,是之前的 0.06s 的 11 倍。究其原因,受到了耗时准备任务的干扰。我们需要用 ResetTimer 屏蔽掉:

func BenchmarkFib(b *testing.B) {
    time.Sleep(time.Second * 3) // 模拟耗时准备任务
    b.ResetTimer() // 重置定时器
    for n := 0; n < b.N; n++ {
        fib(30) // run fib(30) b.N times
    }
}

运行结果恢复正常,每次调用约 0.06s。

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50           6187485 ns/op
PASS
ok      example 6.330s

2 StopTimer & StartTimer

还有一种情况,每次函数调用前后需要一些准备工作和清理工作,我们可以使用 StopTimer 暂停计时以及使用 StartTimer 开始计时。
例如,如果测试一个冒泡函数的性能,每次调用冒泡函数前,需要随机生成一个数字序列,这是非常耗时的操作,这种场景下,就需要使用 StopTimer 和 StartTimer 避免将这部分时间计算在内。
例如:

// sort_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generateWithCap(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0, n)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func bubbleSort(nums []int) {
    for i := 0; i < len(nums); i++ {
        for j := 1; j < len(nums)-i; j++ {
            if nums[j] < nums[j-1] {
                nums[j], nums[j-1] = nums[j-1], nums[j]
            }
        }
    }
}

func BenchmarkBubbleSort(b *testing.B) {
    for n := 0; n < b.N; n++ {
        b.StopTimer()
        nums := generateWithCap(10000)
        b.StartTimer()
        bubbleSort(nums)
    }
}

执行该用例,每次排序耗时约 0.1s。

$ go test -bench='Sort$' .
goos: darwin
goarch: amd64
pkg: example
BenchmarkBubbleSort-8                  9         113280509 ns/op
PASS
ok      example 1.146s
posted @ 2024-03-14 23:21  liuyang9643  阅读(10)  评论(0编辑  收藏  举报