建立链接网站模板,网站建设维护方案,长沙网站建设推广,wordpress网站统计基准测试
确定代码是快或慢非常复杂。我们不用自己计算#xff0c;应使用Go测试框架内置的基准测试。下面来看第15章的GitHub代码库sample_code/bench目录下的函数#xff1a;
func FileLen(f string, bufsize int) (int, error) {file, err : os.Open(f)if err ! …基准测试
确定代码是快或慢非常复杂。我们不用自己计算应使用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 : 0for {buf : make([]byte, bufsize)num, err : file.Read(buf)count numif 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 intfunc 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, nilcase ( 2 2 ) * 10:return 40, nilcase ( 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}}, nilcase 2:return []Pet{{Name: Stampy}, {Name: Snowball II}}, nildefault:return nil, fmt.Errorf(invalid id: %s, userID)}
}
然后编写单元测试将stub插入Logic
func TestLogicGetPetNames(t *testing.T) {data : []struct {name stringuserID stringpetNames []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 stringgetPets func(userID string) ([]Pet, error)userID stringpetNames []stringerrMsg 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 stringif 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 stringClient *http.Client
}func (rs RemoteSolver) Resolve(ctx context.Context, expression string)(float64, error) {req, err : http.NewRequestWithContext(ctx, http.MethodGet,rs.MathServerURL?expressionurl.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 stringcode intbody 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 stringio inforesult 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.ioresult, err : rs.Resolve(context.Background(), d.io.expression)if result ! d.result {t.Errorf(io %f, got %f, d.result, result)}var errMsg stringif 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。为有助找到这类bugGo中内置了一个竞态检查器。它并不保证能找到代码中的每个数据竞争如若找到应对其添加适当的锁。
我们来看第15章的GitHub代码库中的简单示例sample_code/race/race.go
func getCounter() int {var counter intvar wg sync.WaitGroupwg.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 -raceWARNING: DATA RACE
Read at 0x00c000128070 by goroutine 10:test_examples/race.getCounter.func1()test_examples/race/race.go:12 0x45Previous 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 stringin []byteout []stringerrMsg 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 stringif 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任意浮点数类型boolstring[]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 -fuzzFuzzParseData
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: EOFFailing input written to testdata/fuzz/FuzzParseData/fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3dfTo re-run:go test -runFuzzParseData/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 -runFuzzParseData/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 -fuzzFuzzParseData
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 rangegoroutine 23027 [running]:runtime/debug.Stack()/usr/local/go/src/runtime/debug/stack.go:24 0x104testing.tRunner.func1()/usr/local/go/src/testing/testing.go:1356 0x258panic({0x1003f9920, 0x10042a260})/usr/local/go/src/runtime/panic.go:884 0x204file_parser.ParseData({0x10042a7c8, 0x14006c39bc0})file_parser/file_parser.go:24 0x254
[...]Failing input written to testdata/fuzz/FuzzParseData/03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1To re-run:go test -runFuzzParseData/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 -fuzzFuzzParseData
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/b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79To re-run:go test -runFuzzParseData/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 -fuzzFuzzParseData
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
几分钟后不再有报错按下controlC终止测试。
模糊测试没有找到其它问题也并不表示代码就没有bug了。但模糊测试让我们可以找到原始代码中忽略的一些错误。编写模糊测试需要一些练习因其与编写单元测试的思维不同。一旦掌握就会成为验证代码如何处理预料外用户输入的基本工具。
小结
本章中我们学习了如何通过Go对测试、代码覆盖率、基准测试、模糊测试和数据竞争检查的内置支持编写测试及提升代码质量。
本文来自正在规划的Go语言云原生自我提升系列欢迎关注后续文章。