云原生系列Go语言篇-编写测试Part 2

基准测试

确定代码是快或慢非常复杂。我们不用自己计算,应使用Go测试框架内置的基准测试。下面来看​​第15章的GitHub代码库​​sample_code/bench目录下的函数:

func FileLen(f string, bufsize int) (int, error) {
    file, err := os.Open(f)
    if err != nil {
        return 0, err
    }
    defer file.Close()
    count := 0
    for {
        buf := make([]byte, bufsize)
        num, err := file.Read(buf)
        count += num
        if err != nil {
            break
        }
    }
    return count, nil
}

这个函数计算文件中的字数。它接收两个参数,文件名和用于读取文件的缓冲大小(稍后会讲到第二个参数的作用)。

在测试其速度前,应当测试代码运行是否正常。以下是简单的测试:

func TestFileLen(t *testing.T) {
    result, err := FileLen("testdata/data.txt", 1)
    if err != nil {
        t.Fatal(err)
    }
    if result != 65204 {
        t.Error("Expected 65204, got", result)
    }
}

下面来看运行该函数需要多长时间。我们的目标是找出该使用多大的缓冲区读取文件。

注:在花时间坠入优化的深渊之前,请明确程序需要进行优化。如果程序已经足够快,满足了响应要求,并且使用的内存量在接受范围之内,那么将时间花在新增功能和修复bug上会更好。业务的需求决定了何为"足够快"和"接受范围之内"。

在 Go 中,基准测试是测试文件中以单词​​Benchmark​​​开头的函数,它们接受一个类型为​​*testing.B​​​的参数。这种类型包含了​​*testing.T​​的所有功能,以及用于基准测试的额外支持。首先看一个使用 1 字节缓冲区的基准测试:

var blackhole int

func BenchmarkFileLen1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        result, err := FileLen("testdata/data.txt", 1)
        if err != nil {
            b.Fatal(err)
        }
        blackhole = result
    }
}

​blackhole​​​ 包级变量是有作用的。我们将 ​​FileLen​​​ 的结果写入这个包级变量,以确保编译器不会自负到优化掉对 ​​FileLen​​ 的调用,而对基准测试产生破坏。

每个 Go 基准测试都必须有一个循环,从 0 迭代到 ​​b.N​​​。测试框架会一遍又一遍地调用我们的基准测试函数,每次传递更大的 ​​N​​ 值,直到确保时间结果准确为止。马上会在输出中看到这一点。

我们通过向​​go test​​​传递​​-bench​​​标记来运行基准测试。该标记接收一个正则表达式来描述要运行的基准测试名称。使用​​-bench=.​​​来运行所有基准测试。第二个标记​​-benchmem​​在基准测试输出中包含内存分配信息。所有测试在基准测试之前运行,因此只有在测试通过时才能对代码进行基准测试。

以下是运行基准测试我电脑上的输出:

BenchmarkFileLen1-12  25  47201025 ns/op  65342 B/op  65208 allocs/op

运行含内存分配信息的基准测试输出有5列。分别如下:

BenchmarkFileLen1-12基准测试的名称,中间杠,加用于测试的GOMAXPROCS的值。25产生稳定输出运行测试的次数。47201025 ns/op该基准测试运行单次通过的时间,单位是纳秒(1秒为1,000,000,000纳秒)。65342 B/op基准测试单次通过所分配的字节数。65208 allocs/op基准测试单次通过堆上分配字节的次数。其值小于等于字节的分配数。

我们已经得到1字节缓冲的结果,下面来看使用其它大小缓冲所得到的结果:

func BenchmarkFileLen(b *testing.B) {
    for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {
        b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                result, err := FileLen("testdata/data.txt", v)
                if err != nil {
                    b.Fatal(err)
                }
                blackhole = result
            }
        })
    }
}

和使用​​t.Run​​​启动表格测试类似,我们使用​​b.Run​​启动不同输入的基准测试。作者电脑上的结果如下:

BenchmarkFileLen/FileLen-1-12          25  47828842 ns/op   65342 B/op  65208 allocs/op
BenchmarkFileLen/FileLen-10-12        230   5136839 ns/op  104488 B/op   6525 allocs/op
BenchmarkFileLen/FileLen-100-12      2246    509619 ns/op   73384 B/op    657 allocs/op
BenchmarkFileLen/FileLen-1000-12    16491     71281 ns/op   68744 B/op     70 allocs/op
BenchmarkFileLen/FileLen-10000-12   42468     26600 ns/op   82056 B/op     11 allocs/op
BenchmarkFileLen/FileLen-100000-12  36700     30473 ns/op  213128 B/op      5 allocs/op

结果符合预期;随着缓冲区大小的增加,分配次数减少,代码运行速度更快,直至缓冲区大于文件的大小。当缓冲区大于文件大小时,会有额外的分配导致输出减慢。如果我们预期文件大致是这个大小,那么10,000 字节的缓冲区效果最佳。

但是有一个改动可以进一步提高性能。现在每次从文件获取下一组字节时都重新分配缓冲区。这是没必要的。如果我们在循环之前进行字节切片分配,然后重新运行基准测试,会看到提升:

BenchmarkFileLen/FileLen-1-12          25  46167597 ns/op     137 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10-12        261   4592019 ns/op     152 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100-12      2518    478838 ns/op     248 B/op  4 allocs/op
BenchmarkFileLen/FileLen-1000-12    20059     60150 ns/op    1160 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10000-12   62992     19000 ns/op   10376 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100000-12  51928     21275 ns/op  106632 B/op  4 allocs/op

现在分配的次数相同且较小,每个缓冲区大小仅需四次分配。有意思的是,我们现在可以作出权衡。如果内存紧张,可以使用较小的缓冲区大小,在牺牲性能的情况下节约内存。

Go代码性能调优

如果基准测试显示存在性能或内存问题,下一步是确定问题的具体原因。Go 包含了分析工具,可从正在运行的程序中收集 CPU 和内存使用数据,还有用于可视化和解释生成的数据的工具。甚至可以暴露一个 Web 服务端点,远程从运行的 Go 服务中收集分析信息。

讨论性能调优工具不在我们的范畴。线上有许多很好的资源提供相关信息。一个不错的起点是 Julia Evans 的博文​​使用 pprof 对 Go 程序做性能分析​​。

Go的桩代码(Stub)

截至目前,我们测试的函数都不依赖其他代码的。但这并不具代表性,因为大多数代码都存在依赖关系。我们学过Go提供了两种方式来抽象函数调用:定义函数类型和定义接口。这些抽象不仅有助写出模块化的生产代码,还有助于我们编写单元测试。

小贴士:在代码有抽象依赖时,编写单元测试会更容易!

来看​​第15章的GitHub代码库​​的sample_code/solver目录中示例代码。我们定义了一个名为 ​​Processor​​ 的类型:

type Processor struct {
    Solver MathSolver
}

其中字段的类型为​​MathSolver​​:

type MathSolver interface {
    Resolve(ctx context.Context, expression string) (float64, error)
}

稍后我们会实现并测试​​MathSolver​​。

​Processor​​​还需要一个从​​io.Reader​​中读取表达式并返回计算值的方法:

func (p Processor) ProcessExpression(ctx context.Context, r io.Reader)
                                    (float64, error) {
    curExpression, err := readToNewLine(r)
    if err != nil {
        return 0, err
    }
    if len(curExpression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := p.Solver.Resolve(ctx, curExpression)
    return answer, err
}

下面编写代码测试​​ProcessExpression​​​。首先,人们需要简单地实现​​Resolve​​方法以供测试:

type MathSolverStub struct{}

func (ms MathSolverStub) Resolve(ctx context.Context, expr string)
                                (float64, error) {
    switch expr {
    case "2 + 2 * 10":
        return 22, nil
    case "( 2 + 2 ) * 10":
        return 40, nil
    case "( 2 + 2 * 10":
        return 0, errors.New("invalid expression: ( 2 + 2 * 10")
    }
    return 0, nil
}

接下来,我们编写使用这一stub的单元测试(生产代码还应测试错误消息,但这里为保持简洁省略该操作):

func TestProcessorProcessExpression(t *testing.T) {
    p := Processor{MathSolverStub{}}
    in := strings.NewReader(`2 + 2 * 10
( 2 + 2 ) * 10
( 2 + 2 * 10`)
    data := []float64{22, 40, 0}
    hasErr := []bool{false, false, true}
    for i, d := range data {
        result, err := p.ProcessExpression(context.Background(), in)
        if err != nil && !hasErr[i] {
            t.Error(err)
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

再进行测试,一切正常。

虽然大部分Go接口仅有一到两个方法,但也有更多的。有时会发现有多个方法的接口。我们来看​​第15章的GitHub代码库​​sample_code/stub目录中的代码。假设有一个这样的接口:

type Entities interface {
    GetUser(id string) (User, error)
    GetPets(userID string) ([]Pet, error)
    GetChildren(userID string) ([]Person, error)
    GetFriends(userID string) ([]Person, error)
    SaveUser(user User) error
}

在测试依赖于大型接口的代码,有两种模式。第一种是将接口内嵌到结构体中。在结构体中内嵌接口会自动在结构体中定义接口的所有方法。它不提供这些方法的具体实现,因此需要实现当前所需测试的方法。假设​​Logic​​​是一个包含​​Entities​​类型字段的结构体:

type Logic struct {
    Entities Entities
}

假如想测试如下方法:

func (l Logic) GetPetNames(userId string) ([]string, error) {
    pets, err := l.Entities.GetPets(userId)
    if err != nil {
        return nil, err
    }
    out := make([]string, len(pets))
    for _, p := range pets {
        out = append(out, p.Name)
    }
    return out, nil
}

这个方法仅使用对​​Entities​​​声明的一个方法,即​​GetPets​​​。不必实现​​GetPets​​​上的所有方法的stub来测试​​GetPets​​,我们可以编写一个仅实现所需测试方法的stub结构体来完成测试:

type GetPetNamesStub struct {
    Entities
}

func (ps GetPetNamesStub) GetPets(userID string) ([]Pet, error) {
    switch userID {
    case "1":
        return []Pet{{Name: "Bubbles"}}, nil
    case "2":
        return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nil
    default:
        return nil, fmt.Errorf("invalid id: %s", userID)
    }
}

然后编写单元测试,将stub插入​​Logic​​:

func TestLogicGetPetNames(t *testing.T) {
    data := []struct {
        name     string
        userID   string
        petNames []string
    }{
        {"case1", "1", []string{"Bubbles"}},
        {"case2", "2", []string{"Stampy", "Snowball II"}},
        {"case3", "3", nil},
    }
    l := Logic{GetPetNamesStub{}}
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            petNames, err := l.GetPetNames(d.userID)
            if err != nil {
                t.Error(err)
            }
            if diff := cmp.Diff(d.petNames, petNames); diff != "" {
                t.Error(diff)
            }
        })
    }
}

(顺便提下,​​GetPetNames​​方法有一个bug。你发现了吗?即便是简单的方法有时也可能存在bug。)

警告:如在stub结构体中嵌入接口,请确保实现测试期间调用的所有方法!如调用未实现的方法,测试会panic。

如仅需为单个测试实现接口中的一个或两个方法,这种方法效果很好。但在需要对不同输入和输出的测试调用相同方法时,其缺点就会暴露出来。这时,需要在同一实现中包含每个测试的各种可能结果,或者为每个测试重新实现该结构体。这很快就会难以理解和维护。更好的解决方案是创建一个将方法调用代理到函数字段的stub结构体。对于​​Entities​​上定义的每个方法,我们在stub结构体中定义一个具有匹配签名的函数字段:

type EntitiesStub struct {
    getUser     func(id string) (User, error)
    getPets     func(userID string) ([]Pet, error)
    getChildren func(userID string) ([]Person, error)
    getFriends  func(userID string) ([]Person, error)
    saveUser    func(user User) error
}

然后通过定义方法来让​​EntitiesStub​​​实现​​Entities​​接口。在各方法中,我们调用相应函数字段。如:

func (es EntitiesStub) GetUser(id string) (User, error) {
    return es.getUser(id)
}

func (es EntitiesStub) GetPets(userID string) ([]Pet, error) {
    return es.getPets(userID)
}

创建好这一stub,就可以通过用于表格测试的数据结构体中的除非来支持不同测试用例中不同方法的实现:

func TestLogicGetPetNames(t *testing.T) {
    data := []struct {
        name     string
        getPets  func(userID string) ([]Pet, error)
        userID   string
        petNames []string
        errMsg   string
    }{
        {"case1", func(userID string) ([]Pet, error) {
            return []Pet{{Name: "Bubbles"}}, nil
        }, "1", []string{"Bubbles"}, ""},
        {"case2", func(userID string) ([]Pet, error) {
            return nil, errors.New("invalid id: 3")
        }, "3", nil, "invalid id: 3"},
    }
    l := Logic{}
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            l.Entities = EntitiesStub{getPets: d.getPets}
            petNames, err := l.GetPetNames(d.userID)
            if diff := cmp.Diff(petNames, d.petNames); diff != "" {
                t.Error(diff)
            }
            var errMsg string
            if err != nil {
                errMsg = err.Error()
            }
            if errMsg != d.errMsg {
                t.Errorf("Expected error `%s`, got `%s`", d.errMsg, errMsg)
            }
        })
    }
}

我们在​​data​​​的匿名结构体中添加了一个函数类型的字段。在每个测试用例中,都指定一个返回与​​GetPets​​​相同数据的函数。通过这种方式编写测试桩,可以清楚地了解每个测试用例应该返回什么。每个测试运行时,我们都会实例化一个新的​​EntitiesStub​​​,并将测试数据中的​​getPets​​​赋值给​​EntitiesStub​​​中的​​getPets​​函数字段。

模拟和桩测试

术语"模拟"(mock)和"桩"(stub)测试经常互换使用,但它们实际上是两个不同的概念。Martin Fowler,一个在与软件开发领域令人尊敬的前辈,写过一篇有关mock测试的​​博客文章​​,讲到了模拟和桩测试之间的区别。简言之,桩测试对给定的输入返回固定的值,而模拟测试则验证一组调用是否按照预期的顺序和预期的输入发生。

在示例中,我们使用测试桩来返回给定响应的固定值。读者可以手动编写自己的模拟测试,或者可以使用第三方库来生成。最流行的两个是Google的​​gomock​​​库和Stretchr的​​testify​​库。

httptest

为调用HTTP服务的函数编写测试可能会很困难。过去这会成为一个集成测试,需要启动一个作为函数调用的服务的测试实例。Go标准库内置​​net/http/httptest​​​包,可以更容易地生成HTTP服务的测试桩。我们回到​​第15章的GitHub代码库​​的sample_code/solver目录,实现一个调用HTTP服务的​​MathSolver​​来评估表达式:

type RemoteSolver struct {
    MathServerURL string
    Client        *http.Client
}

func (rs RemoteSolver) Resolve(ctx context.Context, expression string)
                              (float64, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        rs.MathServerURL+"?expression="+url.QueryEscape(expression),
        nil)
    if err != nil {
        return 0, err
    }
    resp, err := rs.Client.Do(req)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    contents, err := io.ReadAll(resp.Body)
    if err != nil {
        return 0, err
    }
    if resp.StatusCode != http.StatusOK {
        return 0, errors.New(string(contents))
    }
    result, err := strconv.ParseFloat(string(contents), 64)
    if err != nil {
        return 0, err
    }
    return result, nil
}

现在来看如何使用​​httptest​​​库在不启动服务端的情况下测试这段代码。代码位于​​第15章的GitHub代码库​​的solver/remote_solver_test.go中的​​TestRemoteSolver_Resolve​​​函数中,以下是要点。首先,我们希望保障传递给函数的数据到达服务端。因此,在测试函数中,我们定义了一个名为​​info​​​的类型来保存输入和输出,以及一个名为​​io​​的变量,该变量被赋予当前的输入和输出值:

type info struct {
    expression string
    code       int
    body       string
}
var io info

接着伪装启动一个远程服务端,使用它来配置​​RemoteSolver​​的实例:

server := httptest.NewServer(
    http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        expression := req.URL.Query().Get("expression")
        if expression != io.expression {
            rw.WriteHeader(http.StatusBadRequest)
            fmt.Fprintf(rw, "expected expression '%s', got '%s'",
                io.expression, expression)
            return
        }
        rw.WriteHeader(io.code)
        rw.Write([]byte(io.body))
    }))
defer server.Close()
rs := RemoteSolver{
    MathServerURL: server.URL,
    Client:        server.Client(),
}

​httptest.NewServer​​​函数在随机未使用的端口上启动一个HTTP服务端。我们需要提供一个​​http.Handler​​​实现在处理请求。因其是服务端,必须在测试完成后关闭。​​http.Handler​​​实例的URL通过​​server​​​实例的​​URL​​​字段指定,以及有一个预配置的​​http.Client​​​与测试服务器之间进行通讯。我们将它们传递给​​RemoteSolver​​。

函数剩下的部分与其它表格测试并无分别:

data := []struct {
        name   string
        io     info
        result float64
    }{
        {"case1", info{"2 + 2 * 10", http.StatusOK, "22"}, 22},
        // remaining cases
    }
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            io = d.io
            result, err := rs.Resolve(context.Background(), d.io.expression)
            if result != d.result {
                t.Errorf("io `%f`, got `%f`", d.result, result)
            }
            var errMsg string
            if err != nil {
                errMsg = err.Error()
            }
            if errMsg != d.errMsg {
                t.Errorf("io error `%s`, got `%s`", d.errMsg, errMsg)
            }
        })
    }

需要注意变量​​io​​由两个不同的闭包捕获:一个用于桩服务器,一个用于运行各条测试。我们在一个闭包中写入、在另一个闭包中读取。在生产代码里这种做法不好,但在单个函数的测试代码完全成立。

集成测试和构建标签

虽然​​httptest​​提供了一种不依赖外部服务的测试方式,但还是应该编写集成测试、连接其它服务的自动化测试。这些可以验证我们对服务API的理解是正确的。挑战是如何对自动化测试进行分组,只应在存在支撑环境时才运行集成测试。同时,集成测试一般比单元测试慢,所以不要频繁测试。

在​​Go语言工具​​中,我们讲到了构建标签,由Go编译器用于控制文件何时编译。虽然它们主要用于让开发者编写针对指定操作系统、CPU或Go版本的代码,但也可以利用其能力指定自定义标签来控制何时编译及运行测试。

让我们尝试使用我们的数学求解项目。通过​​docker pull jonbodner/math-server​​​使用​​Docker​​​下载一个服务实现,然后在本地使用​​docker run -p 8080:8080 jonbodner/math-server​​命令将服务运行在8080端口上。

注: 如果读者没有安装Docker,或者希望自行构建代码,可以在​​GitHub​​上找到相关代码。

我们需要编写一个集成测试,以确保我们的​​Resolve​​​方法正确地与数学服务器进行通信。​​第15章的GitHub代码库​​​中的​​sample_code/solver/remote_solver_integration_test.go​​​文件中的​​TestRemoteSolver_ResolveIntegration​​函数包含了一个完整的测试。这个测试看起来和我们之前编写的表格测试一样。要注意的是文件的第一行,包声明之前由一行分隔,如下所示:

//go:build integration

与我们所编写的其它测试一同运行集成测试,使用:

$ go test -tags integration -v ./...

使用-short​标记

另一种分组测试的方法是使用​​go test​​​命令加​​-short​​标记。如果希望跳过执行时间较长的测试,可以通过在测试函数开头添加以下代码来标记出慢速测试:

if testing.Short() { t.Skip("skipping test in short mode.") }

在只希望运行短测试时,对​​go test​​​传递​​-short​​标记。

使用​​-short​​​标记运行短测试时需要注意一些问题。如果使用该标记,测试仅分为两个级别:短测试和所有测试。通过使用构建标签,可以对集成测试分组,指定它们运行需要使用的服务。另一个不使用​​-short​​​标记来表示集成测试的理由是逻辑上的。构建标签表示依赖关系,而​​-short​​​标记只是表示不希望运行耗时很长的测试。这是不同的概念。最后,我认为​​-short​​标记不直观。始终应该运行短测试。更合理的做法是用一个标记来包含长时间运行的测试,而不是排除它们。

通过竞态检查器发现并发问题

虽然Go内置支持并发,还是会出现bug。很容易在不获取锁而误在两个不同的协程中引用同一变量。在计算机科学中这称为数据竞争(data race)。为有助找到这类bug,Go中内置了一个竞态检查器。它并不保证能找到代码中的每个数据竞争,如若找到,应对其添加适当的锁。

我们来看​​第15章的GitHub代码库​​中的简单示例sample_code/race/race.go :

func getCounter() int {
    var counter int
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            for i := 0; i < 1000; i++ {
                counter++
            }
            wg.Done()
        }()
    }
    wg.Wait()
    return counter
}

这段代码启动了5个协程,每个协程都对共享变量​​counter​​进行1000次更新,然后返回结果。预期结果为5000,那么我们就用sample_code/race/race_test.go中的单元测试进行验证吧:

func TestGetCounter(t *testing.T) {
    counter := getCounter()
    if counter != 5000 {
        t.Error("unexpected counter:", counter)
    }
}

如果多次运行​​go test​​,会发现有时会通过,但大多数时候会得到这样的错误消息:

unexpected counter: 3673

问题在于代码中存在数据竞争。在简单的程序中,原因很明显:多个协程尝试同时更新​​counter​​​而部分更新丢失了。在更复杂的程序中,这些竞争会更难发现。我们来看竞态检查器有什么功能。对​​go test​​​使用​​-race​​标记来进行启用:

$ go test -race
==================
WARNING: DATA RACE
Read at 0x00c000128070 by goroutine 10:
  test_examples/race.getCounter.func1()
      test_examples/race/race.go:12 +0x45

Previous write at 0x00c000128070 by goroutine 8:
  test_examples/race.getCounter.func1()
      test_examples/race/race.go:12 +0x5b

跟踪信息清晰地表明​​counter++​​行是问题的根源。

警告: 有些人试图通过在代码中插入sleep来修复竞态条件,以将多个协程访问的变量的访问岔开。这种做法很糟糕。这样做可能在某些情况下消除问题,但代码仍然是错误的,在一些情况下会失败。

还可以在构建程序时使用​​-race​​标记。这会创建一个包含竞态检查器的二进制文件,并将它找到的所有竞态报告到控制台。这样在没有测试的代码中可找到数据竞争。

竞态检查器这么有用,为什么不在所有测试和生产环境中始终启用它呢?启用​​-race​​的二进制运行速度约比正常二进制慢10倍。对于需要几分钟才能运行的大型测试套件,这不什么是问题,但对于运行时间仅为一秒的测试套件来说,10倍慢的速度会降低生产效率。

模糊测试

每个开发人员最终都会学到的一项最重要的教训是所有数据都不可信。无论数据格式规范得多好,最终都将得处理与期望所不匹配的输入。这并不仅仅因恶意所致。数据在传输过程、存储甚至在内存中都可能受到损坏。处理数据的程序可能存在bug,而数据格式规范总会有一些边界情况,不同的开发人员的解释方式也会不同。

即使开发人员编写了良好的单元测试,也不可能考虑到所有情况。我们已经了解到,即使具有100%的单元测试覆盖率,也不能保证代码没有bug。需要用生成的数据来补充单元测试,这些数据可能会以预料外的方式破坏程序。这就用到了模糊测试。

模糊测试(Fuzzing)是一种生成随机数据并将其提交给代码以查看它是否正确处理意外输入的技术。开发人员可以提供一个种子语料库或一组已知的好数据,模糊测试器使用这些数据来生成有问题的输入。我们来看如何使用Go测试工具中的模糊测试来发现额外的测试用例。

假设我们正在编写一个处理数据文件的程序。示例代码位于​​GitHub​​上。我们发送了一个字符串列表,但希望高效地分配内存,因此文件中的字符串数以第一行发送,其余行为文本行。以下是处理该数据的示例函数:

func ParseData(r io.Reader) ([]string, error) {
    s := bufio.NewScanner(r)
    if !s.Scan() {
        return nil, errors.New("empty")
    }
    countStr := s.Text()
    count, err := strconv.Atoi(countStr)
    if err != nil {
        return nil, err
    }
    out := make([]string, 0, count)
    for i := 0; i < count; i++ {
        hasLine := s.Scan()
        if !hasLine {
            return nil, errors.New("too few lines")
        }
        line := s.Text()
        out = append(out, line)
    }
    return out, nil
}

我们使用​​bufio.Scanner​​​逐行从​​io.Reader​​​中读取。如果没有供读取的数据,返回一个错误。然后读取第一行并尝试将其转化为命名为的整型​​count​​​。如转化失败,返回错误。接着,为字符串切片分配内存并从Scanner中读取​​count​​行。如果行数不足,返回错误。一切正常的话,返回所读取的行数。

已编写了验证该代码的单元测试:

func TestParseData(t *testing.T) {
    data := []struct {
        name   string
        in     []byte
        out    []string
        errMsg string
    }{
        {
            name:   "simple",
            in:     []byte("3\nhello\ngoodbye\ngreetings\n"),
            out:    []string{"hello", "goodbye", "greetings"},
            errMsg: "",
        },
        {
            name:   "empty_error",
            in:     []byte(""),
            out:    nil,
            errMsg: "empty",
        },
        {
            name:   "zero",
            in:     []byte("0\n"),
            out:    []string{},
            errMsg: "",
        },
        {
            name:   "number_error",
            in:     []byte("asdf\nhello\ngoodbye\ngreetings\n"),
            out:    nil,
            errMsg: `strconv.Atoi: parsing "asdf": invalid syntax`,
        },
        {
            name:   "line_count_error",
            in:     []byte("4\nhello\ngoodbye\ngreetings\n"),
            out:    nil,
            errMsg: "too few lines",
        },
    }
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            r := bytes.NewReader(d.in)
            out, err := ParseData(r)
            var errMsg string
            if err != nil {
                errMsg = err.Error()
            }
            if diff := cmp.Diff(out, d.out); diff != "" {
                t.Error(diff)
            }
            if diff := cmp.Diff(errMsg, d.errMsg); diff != "" {
                t.Error(diff)
            }
        })
    }
}

单元测试对​​ParseData​​有100%的行覆盖率,处理了所有的错误分支。你可能觉得代码已可以上生产,但我们来看模糊测试能否帮忙找到我们未考虑到的错误。

:模糊测试消耗大量资源。一个模糊测试可能会分配(或尝试分配)好几G 的内存,并可能在本地磁盘上写几个 G 的内容。如果在该机器上同时运行的其它程序变慢了,请做好心理准备。

先来编写模糊测试:

func FuzzParseData(f *testing.F) {
    testcases := [][]byte{
        []byte("3\nhello\ngoodbye\ngreetings\n"),
        []byte("0\n"),
    }
    for _, tc := range testcases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, in []byte) {
        r := bytes.NewReader(in)
        out, err := ParseData(r)
        if err != nil {
            t.Skip("handled error")
        }
        roundTrip := ToData(out)
        rtr := bytes.NewReader(roundTrip)
        out2, err := ParseData(rtr)
        if diff := cmp.Diff(out, out2); diff != "" {
            t.Error(diff)
        }
    })
}

模糊测试与标准单元测试很像。函数名以​​Fuzz​​​开头,唯一的参数是​​*testing.F​​类型,没有返回值。

接下来,我们配置一个种子语料库,由一到多个样本数据集组成。这些数据可以成功运行,也可以出错,甚至可能会panic。重要的是,你清楚提供这些数据时程序的行为,并且模糊测试会考虑到这种行为。这些样本数据会由模糊测试器修改生成不良输入。我们的示例只使用了每个条目的一个数据字段(一个字节切片),但你可以使用尽可能多的字段。目前,语料库条目中的字段仅限于以下类型:

  • 任意整数类型(包括无符号类型、​​rune​​​和​​byte​​)
  • 任意浮点数类型
  • ​bool​
  • ​string​
  • ​[]byte​

语料库中的每个条目都传递给​​*testing.F​​​实例上的​​Add​​方法。在本例中,每个条目都是一个字节切片:

f.Add(tc)

如果进行模糊测试的函数需要一个​​int​​​和​​string​​​,对​​Add​​的调用就会是这样:

f.Add(1, "some text")

向​​Add​​传递无效类型的值报运行时错误。

接下来,我们在​​*testing.F​​​实例上调用​​Fuzz​​​方法。这与编写标准单元测试中的表格测试时调用​​Run​​​有点像调。​​Fuzz​​​接受一个参数,一个函数,其第一个参数的类型为​​*testing.T​​​,其余参数的类型、顺序和数量与传递给​​Add​​的值完全匹配。这也指定了在模糊测试期间由模糊测试引擎生成的数据类型。Go编译器无法强制执行这个约束,因此如果未遵循这个约定,就会导致运行时错误。

最后,让我们看一下模糊测试的主体。记住,模糊测试用于查找无法正确处理不良输入的情况。由于输入是随机生成的,我们无法编写输出具体是什么的测试。相反,我们必须使用对所有输入都为真的测试条件。对于​​ParseData​​来说,可以检查两类:

  • 代码是否会对不良输入返回错误,或者是否会panic?
  • 如果你将字符串切片转换回字节切片并重新解析它,是否会得到相同的结果?

我们来看运行模糊测试时会发生什么:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/243 completed
fuzz: elapsed: 0s, gathering baseline coverage: 243/243 completed,
    now fuzzing with 8 workers
fuzz: minimizing 289-byte failing input file
fuzz: elapsed: 3s, minimizing
fuzz: elapsed: 6s, minimizing
fuzz: elapsed: 9s, minimizing
fuzz: elapsed: 10s, minimizing
--- FAIL: FuzzParseData (10.48s)
    fuzzing process hung or terminated unexpectedly while minimizing: EOF
    Failing input written to testdata/fuzz/FuzzParseData/
        fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
    To re-run:
    go test -run=FuzzParseData/
        fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
FAIL
exit status 1
FAIL    file_parser     10.594s

如未指定​​-fuzz​​标志,模糊测试将被视作单元测试,并以种子语料库运行。一次只能对一个模糊测试进行模糊测试。

注: 如想要完整体验,可以删除​​testdata/fuzz/FuzzParseData​​目录的内容。这会使用模糊测试器生成新的种子语料库条目。由于模糊测试器生成随机输入,样本可能与所显示的不同。不过,不同的条目可能会产生类似的错误,虽然顺序可能不同。

模糊测试运行了几秒钟,然后失败了。在这种情况下,​​go​​​命令报告它已崩溃。我们不希望程序崩溃,因此来看一下生成的输入。每次测试用例失败时,模糊测试器都会将它写入与失败的测试相同包中的​​testdata/fuzz/TESTNAME​​​子目录中,在种子语料库中添加一个新的条目。文件中的新种子语料库条目现在成为一个新的单元测试,由模糊测试器自动生成。每当​​go test​​​运行​​FuzzParseData​​函数时,它都会运行,并在我们修复了错误后充当回归测试。

以下是文件的内容:

go test fuzz v1
[]byte("300000000000")

第一行表示模糊测试的测试数据的头。后续行为导致错误的数据。

错误消息表明在重新运行测试时如何隔离出错的分支:

$ go test -run=FuzzParseData/
    fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
signal: killed
FAIL    file_parser     15.046s

问题是我们在尝试分配一个能存储300,000,000,000字符串容量的切片。所需的RAM比我电脑的要多。我们需要将预期的文本元素限定到合适的数量。通过在​​ParseData​​中解析预期行数之后添加如下代码将最大行数设置为1,000:

if count > 1000 {
        return nil, errors.New("too many")
    }

再测试运行模糊测试查看是否有其它错误:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/245 completed
fuzz: elapsed: 0s, gathering baseline coverage: 245/245 completed,
    now fuzzing with 8 workers
fuzz: minimizing 29-byte failing input file
fuzz: elapsed: 2s, minimizing
--- FAIL: FuzzParseData (2.20s)
    --- FAIL: FuzzParseData (0.00s)
        testing.go:1356: panic: runtime error: makeslice: cap out of range
            goroutine 23027 [running]:
            runtime/debug.Stack()
                /usr/local/go/src/runtime/debug/stack.go:24 +0x104
            testing.tRunner.func1()
                /usr/local/go/src/testing/testing.go:1356 +0x258
            panic({0x1003f9920, 0x10042a260})
                /usr/local/go/src/runtime/panic.go:884 +0x204
            file_parser.ParseData({0x10042a7c8, 0x14006c39bc0})
                file_parser/file_parser.go:24 +0x254
[...]
    Failing input written to testdata/fuzz/FuzzParseData/
        03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1
    To re-run:
    go test -run=FuzzParseData/
        03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1
FAIL
exit status 1
FAIL    file_parser     2.434s

这次的测试结果中产生了panic。查看​​go fuzz​​生成的文件,可以看到:

go test fuzz v1
[]byte("-1")

导致panic的行为:

out := make([]string, 0, count)

我们在尝试创建容量为负数的切片,产生了panic。在代码添加一个条件发现负数的情况:

if count < 0 {
        return nil, errors.New("no negative numbers")
    }

再次运行测试,会出现另一个错误:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/246 completed
fuzz: elapsed: 0s, gathering baseline coverage: 246/246 completed,
    now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 288734 (96241/sec), new interesting: 0 (total: 246)
fuzz: elapsed: 6s, execs: 418803 (43354/sec), new interesting: 0 (total: 246)
fuzz: minimizing 34-byte failing input file
fuzz: elapsed: 7s, minimizing
--- FAIL: FuzzParseData (7.43s)
    --- FAIL: FuzzParseData (0.00s)
        file_parser_test.go:89:   []string{
            -   "\r",
            +   "",
              }


    Failing input written to testdata/fuzz/FuzzParseData/
        b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79
    To re-run:
    go test -run=FuzzParseData/
        b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79
FAIL
exit status 1
FAIL    file_parser     7.558s

查看所创建的文件,生成的是仅包含\r(回车)字符的空行。我们没考虑输入中有空行,所以在读取​​Scanner​​中文本行的循环中添加一些代码。我们会检测某行是否仅包含空白字符。如是,则返回错误:

line = strings.TrimSpace(line)
        if len(line) == 0 {
            return nil, errors.New("blank line")
        }

再次运行模糊测试:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/247 completed
fuzz: elapsed: 0s, gathering baseline coverage: 247/247 completed,
    now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 391018 (130318/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 6s, execs: 556939 (55303/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 9s, execs: 622126 (21734/sec), new interesting: 2 (total: 249)
[...]
fuzz: elapsed: 2m0s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
fuzz: elapsed: 2m3s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
^Cfuzz: elapsed: 2m4s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
PASS
ok      file_parser     123.662s

几分钟后,不再有报错,按下control+C终止测试。

模糊测试没有找到其它问题也并不表示代码就没有bug了。但模糊测试让我们可以找到原始代码中忽略的一些错误。编写模糊测试需要一些练习,因其与编写单元测试的思维不同。一旦掌握,就会成为验证代码如何处理预料外用户输入的基本工具。

小结

本章中,我们学习了如何通过Go对测试、代码覆盖率、基准测试、模糊测试和数据竞争检查的内置支持编写测试及提升代码质量。

本文来自正在规划的​​Go语言&云原生自我提升系列​​,欢迎关注后续文章。

你可能感兴趣的:(Golang&云原生,golang,开发语言,后端,单元测试,云原生)