《100 Go Mistakes and How to Avoid Them》之测试篇

2023-07-26 ⏳6.1分钟(2.5千字)

11.1 没有把测试用例分类

下图的测试金字塔就是将测试分类成不同的种类.

单元测试为金字塔的基础。大部分的测试应该都是单元测试:可以简单的编写,快速的运行,有着高确定性。当我们沿着金字塔往上走,测试会变得更加复杂,并且运行的越来越慢。常用的手段应该是确定哪些类型的测试需要执行。如果测试不分类就会意味着浪费时间以及效率。

11.1.1 构建tag

最常见的方法去分类测试就是使用构建tag。比如:

//go:build integration
package db

import (
    "testing"
)
func TestInsert(t *testing.T) {
    // ...
}

这样我们执行go test --tags=integration -v .后,就会跑有integration标签以及没有标签的测试。

11.1.2 环境变量

tag的方式有个缺点就是我们有时会忘记flag导致在不知情的情况下跳过了一些测试。所以使用环境变量去控制测试也是一种方式:

func TestInsert(t *testing.T) {
    if os.Getenv("INTEGRATION") != "true" {
        t.Skip("skipping integration test")
    }
// ... }

这样的优势是我们明确的知道哪些测试被跳过了以及被跳过的原因

11.1.3 short mode

Go也提供了short模式用于跳过一些比较耗时的测试用例。

func TestLongRunning(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping long-running test")
    }
    // ... 
}

使用的时候带上-short:

% go test -short -v .
=== RUN   TestLongRunning
    foo_test.go:9: skipping long-running test
--- SKIP: TestLongRunning (0.00s)
PASS
ok      foo  0.174s

11.2 没使用过-race标记

当两个协程同时应用于同一个变量,并且至少有一个是写操作,那么我们就定义为数据竞争。 Go有标准的竞态检测工具,一个常见的错误就是忘记这个工具的重要性并且不使用它。在Go中,竞态检测并不是一个静态分析器,而是在运行时的时候去执行,我们可以在跑测试用例的时候使用-race

$ go test -race ./...

应用过程中,编译器会增加一个额外的命令:追踪所有的内存并且记录发生的时间。在运行时,竞态检测器就会检查数据竞争。所以,运行时的性能会变差:

所以我们正常都会在本地运行或者在CI中。

package main

import (
    "fmt"
)
func main() {
    i := 0
    go func() { i++ }()
    fmt.Println(i)
}
==================
WARNING: DATA RACE
Write at 0x00c000026078 by goroutine 7:
  main.main.func1()
      /tmp/app/main.go:9 +0x4e
Previous read at 0x00c000026078 by main goroutine:
  main.main()
      /tmp/app/main.go:10 +0x88
Goroutine 7 (running) created at:
  main.main()
      /tmp/app/main.go:9 +0x7a
==================

总而言之,我们需要记得跑测试的时候在并发场景中需要开启-race

11.3 没有使用测试执行模式

我们跑测试用例的时候,go命令可以接收一些参数去影响测试的执行过程。我们可以了解下parallelshuffle

11.3.1 parallel

parallel允许我们并发跑测试,使用的时候调用t.Parallel

func TestFoo(t *testing.T) {
    t.Parallel()
    // ... 
}

我们还可以设置并发度,默认是GOMAXPROCS的值:

$ go test -parallel 16 .

11.3.1 shuffle

Go1.17之后,我们可以随机测试用例的顺序。最佳实践就是测试用例应该是独立的隔离的,不依赖执行顺序以及共享变量。我们可以使用-shuffle去打乱测试:

$ go test -shuffle=on -v .

我们也可以通过设置随机种子去复现远端的问题:

$ go test -shuffle=1636399552801504000 -v .

我们需要仔细了解已知的一些test flag。同时也需要关注Go新版本的一些新特性。

11.4 没有使用表驱动测试

表驱动测试是非常高效的方式去凝聚测试用例以及避免一些样板代码。当你的函数有多种case需要去测试的时候你会怎么处理?下面举一个例子:

func removeNewLineSuffixes(s string) string {
    if s == "" {
        return s 
    }
    if strings.HasSuffix(s, "\r\n") {
        return removeNewLineSuffixes(s[:len(s)-2])
    }
    if strings.HasSuffix(s, "\n") {
        return removeNewLineSuffixes(s[:len(s)-1])
    }
    return s 
}

我们需要去除后缀,我们设计了以下几个case:

通过表驱动的测试用例如下:

func TestRemoveNewLineSuffix(t *testing.T) {
    tests := map[string]struct {
        input string
        expected string
    }{
        `empty`: {
            input:    "",
            expected: "",
        },
        `ending with \r\n`: {
            input:    "a\r\n",
            expected: "a",
        },
        `ending with \n`: {
            input:    "a\n",
            expected: "a",
        },
        `ending with multiple \n`: {
            input:    "a\n\n\n",
            expected: "a",
        },
        `ending without newline`: {
            input: "a",
            expected: "a",
        },
    }
    for name, tt := range tests {
        t.Run(name, func(t *testing.T) {
            got := removeNewLineSuffixes(tt.input)
            if got != tt.expected {
                t.Errorf("got: %s, expected: %s", got, tt.expected)
            } 
        })
    }
}

上述例子中,key为测试的内容,value为入参以及期望的出参。避免了重复写样板代码。同时我们也可以加上t.Parallel()开启并发。

11.5 在单元测试中Sleep

经常会有一些不可靠的测试用例(代码没有任何变动就可能影响成功或失败)。用了time.Sleep就很容易出这样的问题。比如,我们在代码中有一些延迟操作放在了协程中,那么我们写测试用例的时候可能这样写:

func TestGetBestFoo(t *testing.T) {
    mock := publisherMock{}
    h := Handler{
        publisher: &mock,
        n: 2,
    }
    foo := h.getBestFoo(42) // Check foo

    time.Sleep(10 * time.Millisecond)
    published := mock.Get() // Check published
}

这样的情况,很容易出现10毫秒并不够。那我们怎么优化这种方式呢?第一种方式,通过不断的重试:

func assert(t *testing.T, assertion func() bool,
    maxRetry int, waitTime time.Duration) {
    for i := 0; i < maxRetry; i++ {
        if assertion() {
            return
        }
        time.Sleep(waitTime)
    }
    t.Fail()
}

// use
assert(t, func() bool {return len(mock.Get()) == 2}, 30, time.Millisecond)

testify测试包中,也提供了重试的特性。而另一种方式,则是使用channel去通知协程完成了:

func TestGetBestFoo(t *testing.T) {
    mock := publisherMock{
        ch: make(chan []Foo),
    }
    defer close(mock.ch)
    h := Handler{
        publisher: &mock,
        n:         2,
    }
    foo := h.getBestFoo(42)

    // Check foo
    if v := len(<-mock.ch); v != 2 {
        t.Fatalf("expected 2, got %d", v)
    } 
}

使用信号的方式可以避免浪费等待时间,重试的方式可以让测试更易懂。总而言之,就是不要写一些不稳定的测试用例。

11.7 没有使用测试工具包

标准库提供了一些工具包用于测试,常见的一个误区就是不了解这些包而自己去造轮子。

11.7.1 httptest包

httptest包(https://pkg.go.dev/net/http/httptest)提供了一些测试HTTP的工具我们可以看两个使用场景,第一个是测试HTTP服务的Handler

func Handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Add("X-API-VERSION", "1.0")
    b, _ := io.ReadAll(r.Body)
    _, _ = w.Write(append([]byte("hello "), b...))
    w.WriteHeader(http.StatusCreated)
}

func TestHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "http://localhost",
        strings.NewReader("foo"))
    w := httptest.NewRecorder()
    Handler(w, req)

    if got := w.Result().Header.Get("X-API-VERSION"); got != "1.0" { 
        t.Errorf("api version: expected 1.0, got %s", got)
    }

    body, _ := ioutil.ReadAll(wordy)
    if got := string(body); got != "hello foo" {
        t.Errorf("body: expected hello foo, got %s", got)
    }

    if http.StatusOK != w.Result().StatusCode {
            t.FailNow()
    } 
}

第二个是测试HTTP客户端

func (c DurationClient) GetDuration(url string,lat1, lng1, lat2, lng2 float64) (time.Duration, error) {
    resp, err := c.client.Post(
        url, "application/json",
        buildRequestBody(lat1, lng1, lat2, lng2),
    )
    if err != nil {
        return 0, err
    }

    return parseResponseBody(resp.Body)
}

func TestDurationClientGet(t *testing.T) {
    srv := httptest.NewServer(
        http.HandlerFunc(
            func(w http.ResponseWriter, r *http.Request) {
                _, _ = w.Write([]byte(`{"duration": 314}`))
            },
        ),
    )
    defer srv.Close()

    client := NewDurationClient()
    duration, err :=client.GetDuration(srv.URL, 51.551261, -0.1221146, 51.57, -0.13) 
    if err != nil {
        t.Fatal(err)
    }
    if duration != 314*time.Second {
        t.Errorf("expected 314 seconds, got %v", duration)
    } 
}

11.7.2 iotest包

iotest包(https://pkg.go.dev/testing/iotest)提供了一些工具测试readers&writers. 比如:

func TestLowerCaseReader(t *testing.T) {  
    err := iotest.TestReader(
        &LowerCaseReader{reader: strings.NewReader("aBcDeFgHiJ")},
        []byte("acegi"),
    )
    if err != nil {
        t.Fatal(err)
    } 
}

此外还提供了一些工具去测试边界情况:

11.8 写了不准确的基准测试

通常情况,我们无需去猜测性能优劣。优化的过程中,会有很多因素最终影响到结果。我们可以直接使用基准测试。然而写基准测试也很容易出错,导致我们得出错误的结论。本章的目标就是去看看基础概念以及使用陷阱。我们先了解下基准测试:

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo() 
    }
}

函数以Benchmark为前缀,测试函数foo在循环中,b.N代表了迭代变量。默认跑一秒,也可以使用-benchtime更改,b.N从一开始增加,最终效率则为benchtime/b.N:

$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4                73          16511228 ns/op

11.8.1 没有重置或暂停定时器

一些场景中,我们需要做一些比较耗时的前置操作,我们需要把它的影响排除在外:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    } 
}

比如以上场景,我们就需要用到ResetTimer:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    }
}

ResetTimer会把使用的时间以及内存分配置0.这样耗时的启动操作就不会影响我们的结果。如果每次循环都需要耗时的启动操作呢?

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        expensiveSetup()
        b.StartTimer()
        functionUnderTest()
    } 
}

11.8.2 对micro-benchmark做出了错误的假设

micro-benchmark(微基准测试)是一种用于测量极小的测试单元,而且非常容易做出错误的判断。比如,我们不确定使用atomic.StoreInt32atomic.StoreInt64的优劣,我们可以尝试写基准测试去比较:

func BenchmarkAtomicStoreInt32(b *testing.B) {
    var v int32
    for i := 0; i < b.N; i++ {
        atomic.StoreInt32(&v, 1)
    } 
}
func BenchmarkAtomicStoreInt64(b *testing.B) {
    var v int64
    for i := 0; i < b.N; i++ {
        atomic.StoreInt64(&v, 1)
    } 
}

我们可以看到结果:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4   197107742   5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4   213917528   5.134 ns/op

我们发现atomic.StoreInt64更快一点。当我们变化一下顺序时,先跑atomic.StoreInt64:

BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4       224900722             5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4       230253900             5.159 ns/op

我们又发现atomic.StoreInt32又更快了,为什么?因为有太多因素都会影响我们的结果了,比如服务器的活动状态,电源管理,指令集的顺序缓存等等。很多都是Go项目之外的。解决方案之一就是:增加基准测试的时间。加大样本数,那结果就会无限趋近于我们期望的值。另一种方式就是: 使用一些额外的工具,比如golang.org/x中的benchstat,可以统计比较多次的执行结果。

$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4    234935682    5.124 ns/op
BenchmarkAtomicStoreInt32-4    235307204    5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4    235548591    5.107 ns/op
BenchmarkAtomicStoreInt64-4    235210292    5.090 ns/op
// ...

然后再使用benchstat

$ benchstat stats.txt
name                time/op
AtomicStoreInt32-4  5.10ns ± 1%
AtomicStoreInt64-4  5.10ns ± 1%

11.8.3 没关注编译优化

我们经常会被编译优化所影响,导致错误的判断基准测试。比如这个Go的issue14813,就是非常经典的问题。

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
    x -= (x >> 1) & m1
    x = (x & m2) + ((x >> 2) & m2)
    x = (x + (x >> 4)) & m4
    return (x * h01) >> 56
}

当我们性能测试的时候:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        popcnt(uint64(i))
    }
}

结果是出乎意料的快:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op

这是因为这个函数被内联优化了,这函数没有造成任何的影响,就会被优化成:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // Empty 
    }
}

所以改正方式就是每次赋值给一个本地变量,然后在最后赋值给一个全局变量:

var global uint64

func BenchmarkPopcnt2(b *testing.B) {
    var v uint64
    for i := 0; i < b.N; i++ {
        v = popcnt(uint64(i))
    }
    global = v 
}

最终的性能测试结果:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
BenchmarkPopcnt2-4      606402058                1.993 ns/op

11.9 没了解所有的Go测试特性

写测试的时候,开发者应该要了解Go的一些测试特性和可选项,可以帮助我们更效率更精确的编写测试用例。

11.9.1 代码覆盖率

开发过程中,查看测试用例的覆盖率很有益,使用-coverprofile

$ go test -coverprofile=coverage.out ./...

这样就创建了一个coverage.out文件,我们可以再使用go tool cover:

$ go tool cover -html=coverage.out

这样就能看到每一行代码是否被覆盖到,默认的:代码覆盖只会分析在当前包下有测试用例的文件。这种情况我们可以使用-coverpkg=./...

11.9.2 包外测试

我们有的时候想要测试外部的访问而非内部细节。这样的话,哪怕内部逻辑变动了,测试用例也能保持不变。还能让我们更清晰我们的API改如何使用。这种情况,我们就可以使用包外测试。在Go中,一个文件夹下的所有文件都属于同一个包,有一个例外:测试文件可以属于xxx_test包。比如:

package counter
    
import "sync/atomic"
        
var count uint64

func Inc() uint64 {
    atomic.AddUint64(&count, 1)
    return count
}

我们可以创建一个测试文件:

package counter_test

import (
    "testing"
    "myapp/counter"
)

func TestCount(t *testing.T) {
    if counter.Inc() != 1 {
        t.Errorf("expected 1")
    }
}

这种情况下就可以确保未使用一些私有变量。

11.9.3 启动与拆除

有些情况下,我们需要准备一些测试环境,我们可以在每个测试用例上或者每一个测试包上处理。针对单个用例,我们可以直接使用defer

func TestMySQLIntegration(t *testing.T) {
    setupMySQL()
    defer teardownMySQL()
    // ... 
}

或者使用t.Cleanup去处理:

func TestMySQLIntegration(t *testing.T) {
    // ...
    db := createConnection(t, "tcp(localhost:3306)/db")
    // ... 
}
func createConnection(t *testing.T, dsn string) *sql.DB {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    t.FailNow()
    }
    t.Cleanup(
        func() {
             _ = db.Close()
        })
    return db 
}

而针对这个测试包,我们可以使用TestMain函数:

func TestMain(m *testing.M) {
    setupMySQL()
    code := m.Run()
    teardownMySQL()
    os.Exit(code)
}

运用上这些实践方法,我们就可以在测试中配置很多复杂的环境。