一些 Go test 技巧

deletelazy · · 1992 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

### 前言 测试驱动开发是一个写出高质量代码的好方法,同时避免将代码越写越烂,并证明你的代码能实现预期的效果。 ### 以Table Driven的方式写测试用例 `Table Driven`的意思就是以表格的形式写好测试用例的输入和期望结果,然后写完所有测试用例之后直接在一个循环里遍历所有测试用例,这样的好处是你只需要专注写测试用例的输入和期望结果就OK了。 ```go package add import "testing" func TestAdd(t *testing.T) { tests := [][]int{ // test cases table {1, 1, 2}, {100, 200, 300}, {-2, 2, 0}, {-3, -5, -8}, {999, -1, 998}, } for _, tc := range tests { // 遍历所有测试用例 if res := Add(tc[0], tc[1]); res != tc[2] { t.Errorf("want: %d, got: %d", tc[2], res) } } } ``` ### 外部测试放在 "_test" 包 在Go中,除了`_test.go`文件,同一个目录下的文件都属于同一个包。将测试用例代码放在不同的包里,那么你写测试用例代码时,就好像这个包的真实的使用者一样。在同一个包内调用或写测试用例可能你觉得没什么问题,但当你需要暴露API给外部调用的时候,就考验你的代码功力了,因为你的任何改动都会影响到使用者。 ![test-file](https://wx-mp-1259374020.file.myqcloud.com/external-test.png) 这样,在你更改内部的实现时也不需要更改测试用例的代码了。 ### 内部测试放在`*_internal_test.go`文件 如果你需要写一些内部调用的测试用例,那么你可以将文件命名为`_internal_test.go`后缀,然后使用相同的package。内部测试要比API接口更加精细,但这是一个能使你的代码更可靠的好方法,尤其适合测试驱动开发。 ![internal-test](https://wx-mp-1259374020.file.myqcloud.com/internal_test.png) 所以,一个完整的测试代码目录结构是这样的: ![tree-dir](https://wx-mp-1259374020.file.myqcloud.com/tree-dir.png) ### 利用interface写出可测试代码 现在假设我们正在实现一个叫`web`的web操作公共库,这个库提供了`Client`对象和`GetData`方法,用于从web服务中取数据,代码如下: ```go package web type Client struct{} func NewClient() Client { return Client{} } func (c Client) GetData() (string, error) { return "data", nil } ``` 接着我们的`foo`包会引用`web`包的`Client`对象`GetData`方法去web服务中取数据,代码如下: ```go package foo import ( "errors" "interfaces/web" ) func Controller() error { webClient := web.NewClient() fromWebAPI, err := webClient.GetData() if err != nil { return err } // do some things based on data from web API if fromWebAPI != "data" { return errors.New("unexpected data") } return nil } ``` 现在我们需要测试Controller方法并分别写了两个测试方法,一个是测试成功获取到数据,另一个是测试两种获取数据失败的情况。 然后,问题来了。我们似乎没有办法同时测试到这些逻辑分支,因为我们没办法改变`web`包里面的逻辑。 ```go package foo_test import ( "testing" "interfaces/foo" ) func TestController_Success(t *testing.T) { err := foo.Controller() if err != nil { t.FailNow() } } func TestController_Failure(t *testing.T) { // 这里我们想返回错误,但似乎比较难。 err := foo.Controller() if err == nil { // 这个测试将会fail :( t.FailNow() } } ``` 到这里似乎把我们难住了,但如果我们将`web`包中的`Client`定义成interface,那我们就可以很容易的替换掉这个`Client`的实现。例如,改成下面这样: ```go package foo import ( "errors" ) type IWebClient interface { GetData() (string, error) } func Controller(webClient IWebClient) error { fromWebAPI, err := webClient.GetData() if err != nil { return err } // do some things based on data from web API if fromWebAPI != "data" { return errors.New("unexpected data") } return nil } ``` 然后,我们就可以很容易的测试`Controller`方法了,我们可以根据需要mock Client的实现。 ```go package foo_test import ( "errors" "testing" "interfaces/foo" ) type MockClient struct { GetDataReturn string } func (mc MockClient) GetData() (string, error) { return mc.GetDataReturn, nil } func TestController_Success(t *testing.T) { err := foo.Controller(MockClient{"data"}) if err != nil { t.FailNow() } } type FailingClient struct{} func (fc FailingClient) GetData() (string, error) { return "", errors.New("oh no") } func TestController_Failure(t *testing.T) { // GetData() 失败分支 err := foo.Controller(FailingClient{}) if err == nil { t.FailNow() } // 错误数据分支 err = foo.Controller(MockClient{"not data"}) if err == nil { t.FailNow() } } ``` 就这样,我们所有代码的分支都已经覆盖到啦~ ### Test Fixtures, Golden Files 在一些场景下,我们需要读取某些资源文件,比如我们在测试对 json 文件的解码功能时,就需要一些示例的 json 文件作为测试 case 的输入。像这种场景,把这些测试过程中用到的辅助文件,通常就叫做`Test Fixtures`。而放置这些文件的最佳位置就是放在叫`testdata`的目录下,主要原因有两条: 1. 测试代码运行时,working dir 就是测试代码的当前路径,可以使用相对路径轻易的访问到。 2. Go编译器在编译时会忽略叫作`testdata`的子目录。 看下面的例子: ```go func helperLoadBytes(t *testing.T, name string) []byte { bytes, err := ioutil.ReadFile("testdata/somefixture.json") if err != nil { t.Fatal(err) } return bytes } ``` 那么,`Golden Files`又是什么呢?Golden Files 其实就是`Test Fixtures`中的一种,当测试用例的输出结果比较简单的时候,我们还可以把输出结果写在测试代码中。 但是当输出结果比较复杂时,直接写入代码已经不太合适了。所以此时,我们通常会把正确结果写入到文件里面,并且测试代码运行时,需要读取这个文件的内容进行比较。 一般`Golden Files`的使用都会配合`Table Driven`,每一个测试 case 的`Golden Files`的名字一般就会以“case名字+.golden” 来命名,这样在编写代码时也会比较简单。 ### 计算测试覆盖率 测试覆盖率表示测试代码覆盖源代码的比例。 Go 1.2引入了一个新的计算test coverage的方法,其原理很简单:在编译之前,重写包的源代码和加入埋点,然后编译和运行重写后的代码,然后根据埋点就能统计出代码的覆盖率了。这个重写其实很简单,因为从测试到运行都是由go的原生工具链控制的。 示例代码: ```go package size func Size(a int) string { switch { case a < 0: return "negative" case a == 0: return "zero" case a < 10: return "small" case a < 100: return "big" case a < 1000: return "huge" } return "enormous" } ``` 测试代码: ```go package size import "testing" type Test struct { in int out string } var tests = []Test{ {-1, "negative"}, {5, "small"}, } func TestSize(t *testing.T) { for i, test := range tests { size := Size(test.in) if size != test.out { t.Errorf("#%d: Size(%d)=%s; want %s", i, test.in, size, test.out) } } } ``` 然后当我们加上`-cover`参数即`go test -cover`,就可以得出测试覆盖率。 ``` % go test -cover PASS coverage: 42.9% of statements ok size 0.026s % ``` 我们再来看看go test内部是怎么加埋点的,下面是编译前埋点后的伪代码: ```go func Size(a int) string { GoCover.Count[0] = 1 // 埋点1 switch { case a < 0: GoCover.Count[2] = 1 // 埋点2... return "negative" case a == 0: GoCover.Count[3] = 1 return "zero" case a < 10: GoCover.Count[4] = 1 return "small" case a < 100: GoCover.Count[5] = 1 return "big" case a < 1000: GoCover.Count[6] = 1 return "huge" } GoCover.Count[1] = 1 return "enormous" } ``` 其实就是在代码的各个分支加上埋点,最好再计算出覆盖率。 另外,go还提供了一种直观炫酷的方式展示测试的覆盖率,能精确到是否覆盖到某一行代码。执行以下命令: ``` go test -coverprofile=coverage.out && go tool cover -html=coverage.out ``` 然后会自动的在浏览器中打开页面,如下: ![coverage.png](https://wx-mp-1259374020.file.myqcloud.com/coverage.png) ### 总结 单元测试是保证代码质量十分重要的一个环节。Go的源码和著名开源库,都是一边写源码一边写单元测试。在选择开源库的时候,测试覆盖率及测试用例的质量可以作为一个重要的指标。 最后在贴下`Dave Cheney`在推特转发的一些关于测试的哲学,十分有意思且有道理。 ![dave-test.png](https://wx-mp-1259374020.file.myqcloud.com/dave-test.png) ### 参考资料 - 《Go 项目单元测试最佳实践》:https://fatsheep9146.github.io/2018/08/19/Go-%E9%A1%B9%E7%9B%AE%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5/ - 《5 simple tips and tricks for writing unit tests in #golang》:https://medium.com/@matryer/5-simple-tips-and-tricks-for-writing-unit-tests-in-golang-619653f90742 - 《The cover story》:https://blog.golang.org/cover - 《Using Go Interfaces for Testable Code》:https://medium.com/swlh/using-go-interfaces-for-testable-code-d2e11b02dea ![qrcode](https://static.studygolang.com/190508/d20b3d9f2790729effb6cbea985e443d.png)

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1992 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传