前言
测试驱动开发是一个写出高质量代码的好方法,同时避免将代码越写越烂,并证明你的代码能实现预期的效果。
以Table Driven的方式写测试用例
Table Driven
的意思就是以表格的形式写好测试用例的输入和期望结果,然后写完所有测试用例之后直接在一个循环里遍历所有测试用例,这样的好处是你只需要专注写测试用例的输入和期望结果就OK了。
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给外部调用的时候,就考验你的代码功力了,因为你的任何改动都会影响到使用者。
这样,在你更改内部的实现时也不需要更改测试用例的代码了。
内部测试放在*_internal_test.go
文件
如果你需要写一些内部调用的测试用例,那么你可以将文件命名为_internal_test.go
后缀,然后使用相同的package。内部测试要比API接口更加精细,但这是一个能使你的代码更可靠的好方法,尤其适合测试驱动开发。
所以,一个完整的测试代码目录结构是这样的:
利用interface写出可测试代码
现在假设我们正在实现一个叫web
的web操作公共库,这个库提供了Client
对象和GetData
方法,用于从web服务中取数据,代码如下:
package web
type Client struct{}
func NewClient() Client {
return Client{}
}
func (c Client) GetData() (string, error) {
return "data", nil
}
接着我们的foo
包会引用web
包的Client
对象GetData
方法去web服务中取数据,代码如下:
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
包里面的逻辑。
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
的实现。例如,改成下面这样:
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的实现。
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
的目录下,主要原因有两条:
- 测试代码运行时,working dir 就是测试代码的当前路径,可以使用相对路径轻易的访问到。
- Go编译器在编译时会忽略叫作
testdata
的子目录。
看下面的例子:
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的原生工具链控制的。
示例代码:
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"
}
测试代码:
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内部是怎么加埋点的,下面是编译前埋点后的伪代码:
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
然后会自动的在浏览器中打开页面,如下:
总结
单元测试是保证代码质量十分重要的一个环节。Go的源码和著名开源库,都是一边写源码一边写单元测试。在选择开源库的时候,测试覆盖率及测试用例的质量可以作为一个重要的指标。
最后在贴下Dave Cheney
在推特转发的一些关于测试的哲学,十分有意思且有道理。
参考资料
- 《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
有疑问加站长微信联系(非本文作者))
