Golang的测试用例不好写怎么办啊啊啊。。。

背景

最近工作中使用Golang开发了一个从主业务中拆分的微服务。业务逻辑不是很复杂,代码量也不是很多。主要涉及到三个SDK的整合,外加一些很基础的处理逻辑。

因为之前有过很长一段时间使用Ruby on Rails进行开发,习惯了测试驱动开发的模式,习惯用测试用例保证自己代码的质量;因此在使用Golang进行业务开发时,总想着为业务代码编写出完备的测试用例,一来保证代码的正确性,方便代码进行回归测试;二来通过测试用例展示业务代码的正确使用方式。

于是想探究一番,Golang中测试用例的正确编写姿势到底是什么样子。

测试用例的场景

理想中的测试

理想中,测试用例场景应该是这样的:我们编写了一个A函数,为了确保A的正确性,我们写了一个ATest函数,专门对A进行测试;ATest函数PASS了,说明A函数是按照期望运行的。

比如下面的MyLoveAge函数,我们假设固定返回18岁,为了保证MyLoveAge的正确性,再写一个TestMyLoveAge的函数。

// MyLoveAge returns mylover's age
func MyLoveAge() int {
	return 18
}

下面的TestMyLoveAge断言了MyLoveAge函数的输出,如果是18岁测试用例就通过,否则就失败。

import (
	"testing"
	"github.com/stretchr/testify/assert"
)

func TestMyLoveAge(t *testing.T) {
	assert.Equal(t, 18, MyLoveAge())
}

实际中的测试用例

真实场景应该是这样的:我们写了一个M函数,M调用了N方法;为了确保M的正确性,我们写一个MTest函数,专门对M进行测试;但是因为N是带状态的,为了编写MTest函数,我们需要想办法仿造(Mock)一个假的N,来保证MTest能运行通过。

但是,造一个假的N往往没那么容易。

import "math/rand"

// MyLoveAge2 returns mylover's age
func MyLoveAge2() int {
	return 18 + TimeFlying()
}

// TimeFlying returns the Time flying.
func TimeFlying() int {
	if rand.Intn(10) == 0 {
		return 0
	}
	panic("NO!!!")
}
// MyLoveAge2的测试文件
import (
	"testing"
	"github.com/stretchr/testify/assert"
)

//需要事先进行一些mock操作,下面的函数才能一直跑成功
func TestMyLoveAge2(t *testing.T) {
	assert.Equal(t, 18, MyLoveAge2())
}

比如,MyLoveAge2调用了TimeFlying函数,这时候如果想使得TestMyLoveAge2能一直保持正常工作,我们有两种选择:1)Mock一个TimeFlying函数,让整个函数永远返回0;2)Mock一个rand.Intn函数,让它永远返回0很可惜,对于Golang来说,这两种方式实施起来都没有那么容易,甚至无法实现。

当代码量进一步增加,各个函数的依赖关系更为复杂时,实现起来将更为棘手。

Ruby V.S. Golang

那么,相对于Golang来说,为什么编写Ruby的测试用例能方便一些呢?这真的要归功于Ruby的元编程语言特性。

Ruby的语言特性,决定了它的灵活性:1)一切皆对象;2)任何对象均可以在运行时拆解并允许重新组装。比如上面提到的TimeFlying函数,Ruby很方便对其进行Mock(本质是对Module中的方法进行Mock,当然Module已被看做一个对象)。

和Ruby类似的思想,Golang中出现了一个gostub的工具,它的思想就是想办法把Golang中一切想Mock的东西变成变量,然后就可以对这个变量进行赋值仿造了。但是因为其需要对业务代码进行改造(比如需要把函数声明成变量),而改造以后就不像是Go的风格,因此并没有被普遍接受。

据说有个测试覆盖率100%的代码库

在我们做基础服务时,发现了jaeger这个开源组件,使用Golang进行开发,且测试用例的覆盖率是100%。在Golang的测试用例那么难写的条件下,它是如何实现100%测试覆盖率的呢?

通过查看其源码,大概能得到一些启示:

# 调用链,A调用了B和C,B调用了M,其中‘↓’代表引入的新状态 
|-A()
	|---B---C(↓)
		|---M

比如对于上面的调用关系链所展示的,对M和B编写测试用例是很直观的,对C和A编写测试用例则需要对新状态或引入的新状态进行Mock。

jaeger项目中大部分的函数在运行过程中,尽量避免引入新的状态,大大方便了其测试用例的编写;少有一些带

可测试代码最佳实践

首先应该知道,有些场景根本无法避免新状态的引入,比如编写Web后端时,和数据库进行交互的函数。但即使这样,也要继续坚持写代码,不然房子还买不买了。。

目前能想到的实践包括:

参考