Go Test基本机制和实践规范

一、单元测试的意义

维护单元测试,需要投入一定的时间和精力,但是作为一个长期迭代的产品,定义完整的单元测试的收益是绝对大于投入的,其意义主要有。

  • 相比于代码创造的时间,工程修改和维护需要占用大量的时间。单元测试的意义,就在于修改和维护的用例可以重复使用,减少重复bug的出现,提升代码的质量和研发速度。

  • 如果某些功能难以写测试用例,那么便说明代码的耦合性较强。因此单元测试,可以检验和保证代码的松散耦合。

本文篇幅有限,因此并不介绍单元测试的基本使用方式。下面会先简单介绍go test的基本机制,然后再介绍一些最佳实践。知其然知其所以然,以便于对最佳实践的更好理解和运用。

二、Go Test的基本机制

1、go test运行机制

现有以下文件目录:

.
├── cat
│   ├── cat.go
│   └── cat_test.go
├── dog
│   ├── dog.go
│   ├── dog_test.go
│   └── dogson
│       ├── dogson.go
│       └── dogson_test.go
├── go.mod
└── main
    ├── main.go
    └── main_test.go

运行如下命令:

go test ./cat ./dog ./main
--------------------------------------------//分割线
ok      testfortest/cat (cached)
ok      testfortest/dog (cached)
ok      testfortest/main        (cached)

那么,go会将package cat , package dog 和 package main 分别编译并构建成3个二进制可执行程序,并独立运行。如下图:

Go Test基本机制和实践规范_第1张图片

以下几点对于理解go test非常重要:

1、go test的编译和运行unit是package,不同package的单元测试互不影响。
2、同package中的所有单元测试被一个进程运行,会互相影响。例如,a单元测试修改了包级变量,b单元测试在运行时就会受到影响。
3、go test不会并发执行单元测试。
4、package的单元测试,不会对子package进行单元测试。例如上例中,dog的单元测试运行,但dogson的单元测试不会运行。
5、注意,main.go中的 main()方法会被忽略。

2、go test的基本模式

2.1 go test -v (不带参数 )

会将当前目录的go源文件和test源文件(以及其他依赖),构建生成独立的二进制文件,然后运行。

2.2 go test -v ./dog ./cat (指定package)

会将制定package的go源文件和test源文件(以及其他依赖),构建生成多个独立的dog, cat二进制文件,然后运行。

小技巧:

1、运行“当前目录及其子目录的”单元测试,使用 go test ./...  命令(3个点)。 
2、运行dog及所有子package下的单元测试,可以使用 go test ./dog/... 。
3、指定package时,需要加上相对路径 ./,否则会默认到$GOROOT中去扫描package。例如go test dog命令,会返回:
    package dog is not in GOROOT (/usr/local/go/src/dog)。
    再例如 go test ...,会运行所有go标准库的单元测试(慎用)。

2.3 go test -v ./dog_test.go (指定源文件)

这种方式比较独特,官方文档中没有具体的描述。但通过尝试,可以发现其特殊机制如下:

  • 同package的依赖,需要手动指定

  • 不同package的依赖,会自动引入。

例如相同的package中:

// dog_test.go 文件
package dog

func TestSimpleDogWang(t *testing.T) {
    fmt.Println(dogWang(1))

}

//dog.go 文件
package dog
func dogWang(volume int) string {
    return fmt.Sprintf("%d 岁(%d岁)的狗:.....wang....(%d)\n", DogOuterAge, dogInnerAge, volume)
}

//不指定依赖
go test ./dog/dog_test.go
结果:dog/dog_test.go:16:14: undefined: dogWang

//手动指定依赖源文件,有多少依赖文件就需要指定多少依赖文件,非常麻烦(不允许package和go文件混合使用)
go test ./dog/dog_test.go ./dog/dog.go
结果:ok      command-line-arguments  0.006s

但是假如你的dog_test.go中依赖的不是package dog,而是外部package,就不需要手动指定依赖。本文篇幅有限,各位有兴趣可以写个demo试一下。

三、最佳实践

1、Table-Driven测试

同一功能测试的input output用表的方式进行定义,例如下面的cases结构体。

package dog

type dogInput struct {
    input  int     //输入数据
    wanted string   //期待参数
}

func TestDogWang(t *testing.T) {
    //table driven
    var cases = make(map[string]dogInput)
    cases["input1"] = dogInput{1, "wang....(1)"}
    cases["input3"] = dogInput{3, "wang....(3)"}
    cases["input4"] = dogInput{4, "wang....(4)"}
    cases["input5"] = dogInput{5, "wang....(5)"}

    for name, d := range cases {
        t.Run(name, func(t *testing.T) {
            output := dogWang(d.input)
            if !strings.Contains(output, d.wanted) {
                t.Errorf("dogWang(%d) = %s , wanted contains %s", d.input, output, d.wanted)
            }
        })
    }

}

其优点在于:

  • 提供test检测程序复用性。

  • 方便对input、output进行维护

  • t.Run(name,func(t *testing.T){})的方式,可以方便的区分失败的输入case。

2、每次出现Bug,增加Case

每次测试测出新的bug,需要将case添加到table中。

3、复杂输入

复杂输入,指当input的值很多、复杂并难以理解时,极容易因为代码的迭代而造成测试用例的修改,造成测试用例的脆弱性,浪费大量的精力用 于维护case。而一个功能的输入过于复杂,对于日后的可读性和可维护性也是不好的,因此需要完善该测试目标的api设计。

4、golden value

golder value,当wanted的值过于复杂时,也会造成测试用例的脆弱性。因此最好优化功能api设计,或者如上例用strings.Contains(output, d.wanted) 代替 output == d.wanted,提升兼容性。

5、不要中断测试用例

不要因为某个测试用例,而中断其他测试用例,因此测试用例和测试目标api不应该包含Fatal() 、 panic 、os.Exit之类的操作(事实上这些操作是main package的特殊权力)。

如上例,可以使用t.Errorf()方法,告诉测试程序该用例失败,而不用影响其他测试用例。

6、统一失败信息格式

用例失败的消息格式,统一使用"F(x) = y ,wanted"的格式,例如上例中,

t.Errorf("dogWang(%d) = %s , wanted contains %s", d.input, output, d.wanted)

通过日志信息即可了解,方法,用例,输出和期待值。

7、白盒测试

白盒测试,就是可以获取非exported 的变量和方法,进行测试。基于白盒测试可以:

  • 实现fake能力,类似于java的mock测试能力

  • 设置一些非常难以构造的状态,例如timeout、connection refused、一些内部状态等等。

白盒测试需要注意的关键点---进行数据恢复,例如:

//cat.go
package cat

var catMiao = func(volume int) string {
    return fmt.Sprintf("%d 岁(%d岁)的猫:.....miao....(%d)\n", CatOuterAge, catInnerAge, volume)
}

//cat_test.go
package cat

func TestCatMiao(t *testing.T) {
    saved := catMiao
    //fake 恢复
    defer func() {
        catMiao = saved
    }()
    //fake
    catMiao = = func(i int) string {
        return "miaomiao"
    }
    fmt.Println(catMiao(2))
}

上例中对cat.go中的catMiao方法进行了mock,你可以在自己的项目中对例如数据库的操作进行fake操作,对package变量进行fake,但需要注意一定要用defer进行恢复,以免污染后续的单元测试。

8、解决循环依赖

假如在dog package依赖了 cat package,然而我们需要在cat package中测试dog package中的关联方法,这就会导致循环依赖,而go编译器是不允许循环依赖发生的。

Go Test基本机制和实践规范_第2张图片

此时即可将cat package中出现循环依赖的package名改为:

package cat_tetst

那么实际上,package cat_test 会在 cat和dog包层级之上

Go Test基本机制和实践规范_第3张图片

这也就解释了,为什么黑盒测试和集成测试,没有循环依赖的问题。

9、黑盒测试和集成测试

黑盒测试和集成测试,只能使用package中导出的变量或方法,而不会使用内部未导出的信息。

以上述cat_test.go为例,只需将package名改为:

package cat_tetst

即可在go test 编译构建时,生成新的package,该package在所有的被依赖包的层级之上,那么该package只能使用被依赖包中exported导出信息,从而实现黑盒测试和集成测试。

10、指定要测试的单元测试

上面已经讲过, go test -v dog.go的方式并不推荐,因为你需要手动指定依赖的go文件,以及依赖的依赖文件,这是十分麻烦的。因此推荐用以下方式:

go test -v -run ^TestExternal$ xsiemhelper/testfor

-run 指定单元测试方法名的正则, xsiemhelper/testfor指定package。像VsCode的Go插件在启动单元测试的时候,就是使用这种风格的命令。

11、Benchmark默认不运行

在运行go test -v xxx的时候,benchmark默认是不运行的,需要使用-bench命令指定对应的基准测试方法名(正则):

go test -v -bench ^BenchmarkOK$ xsiemhelper/testfor

12、IDE不一致的报错

默认情况下运行go test命令,会运行go vet来进行语法检查,例如:

fmt.Println("my name is %s","hahah")

就会被认为是语法警告,fmt.Println call has possible formatting directive %s,并且编译无法通过。

Goland不进行vet测试,Vscode的Go插件会进行vet检测,会造成各开发小伙伴不一致的现象,建议通过设置,统一进行vet检测,保证代码质量。

13、Test的WorkDirectory

wd总是在变化的,就是_test.go当前所在的目录,而不是你执行go test的目录。这点和go run 不一样,go run是你在什么路径下执行该命令,那么workdirectory就是什么路径。这点需要注意。

14、创建和清理test进程所需要的资源

使用TestMain,该package的测试入口就是TestMain方法,可以用国语创建和销毁所需要的系统资源,例如文件、连接等。

func TestMain(m *testing.M) {
    fmt.Println("begin")
    m.Run()
    fmt.Println("end")
}

你可能感兴趣的:(GoLang,golang,单元测试,go,test,最佳实践,原理)