编写测试友好的 Golang 代码

目前我们有大量的应用采用了 Golang 程序进行构建,但是在执行研发流程里我们会发现一些来自于静态编译程序的不便:相对于我们之前使用的 Python 语言程序而言,我们无法在程序功能的单元测试里大量的使用 Mock 方式来进行高效测试。

而这些东西往往可以在开发人员编写单元测试用例时有效的节省时间和一些额外的环境准备成本。因此,这也给我们的程序的单元覆盖率带来了很多麻烦的地方:一些依赖于额外验证和表现的情况或者小几率出现的情况需要复杂的模拟步骤,对开发进度和效率带来了一些额外的影响。如何编写一个测试友好的 Golang 程序成为一个无法绕开的问题。

从动态语言到静态语言

动态语言有良好的运行时修改属性,在运行时的动态修改函数,可以进行有效的 Mock。比如在 Python(以 3 为例,内置了 unittest.mock 标准库)程序中:

with patch.object(ProductionClass,'method', return_value=None) as mock_method:
    thing = ProductionClass()
    thing.method(1, 2, 3)

自然而然的,我们想到了这样的用法:

var imp = func() bool {return true}

func TestFunc(t *testing.T) {defer func(org func() bool) {imp = org}(imp)
	
	img = func() bool {return false}
	// testing or something else...
}

这样实现 Mock 是完全可以的,但是实际上会带来一些额外的问题,比如说在 MVC 框架中,我们正常采用的方式一般是这样的:

import ("models"
	...
)

func A(ctx Context) error {
	...
	data := models.Data()
	...
}

这种方式则是无法在运行中进行动态 Mock 的,除非将其转换为参数方式进行调用。

func TestFunc(t *testing.T) {Convey("test", t, func() {defer func(org func() string) {models.Data = org  // Error: cannot assign to models.Data}(models.Data)

		models.Data = func() string {return"mocked!"}
		
		....
	})
}

转成

// var data = models.Data
// in A: data := data()

func TestFunc(t *testing.T) {Convey("test", t, func() {defer func(org func() string) {data = org}(data)

		data = func() string {return"mocked!"}})
}

这样写法略微会多处大量的临时函数指针变量,如果是使用这种方式则需要额外的变量值的对应关系,测试完成后变量值需要恢复成原有指针(如果需要测试正常功能)。

从变量到接口

除了上面介绍的方法以外,是不是还有看起来稍微优雅一点的测试方法呢?我们尝试将上面的函数形式换成下面的接口形式,将 interface 对应的变量作为全局变量。

// main.go
var fetcher DataFetcherInterface

type DataFetcherInterface interface {Data() string
}

type DataFetcher struct {
}

func (d DataFetcher) Data() string {return"hello world!"}

func Func(w http.ResponseWriter, r *http.Request) {fmt.Fprintf(w,"%s", fetcher.Data())
}

func main() {fetcher = DataFetcher{}
	http.HandleFunc("/", Func)
	http.ListenAndServe("127.0.0.1:12821", nil)
}

这样的话我们就可以在测试文件里面定义一个 FakeDataFetcher,实现相关的功能:

// main_test.go
type FakeDataFetcher struct {
}

func (f FakeDataFetcher) Data() string {return"mocked!"}

func TestFunc(t *testing.T) {Convey("test", t, func() {defer func(org DataFetcherInterface) {fetcher = org}(fetcher)

		fetcher = FakeDataFetcher{}

		req, _ := http.NewRequest("GET", "http://example.com/", nil)
		w := httptest.NewRecorder()
		Func(w, req)
		So(w.Body.String(), ShouldEqual, "mocked!")
	})
}

这样可以减少变量的生成个数,同时,也可以通过 FakeDataFetcher{} 传入不同的参数,实现不同的 Faker 测试。值得注意的是,在这个 interface 方法中需要特别注意变量共享的线程安全问题。

依赖注入

上面两种方法似乎思路类似,除了这些方案之外,还有没有其他的方案呢?最后介绍一下依赖注入的方式,这种方式也可以与上面提到的接口方式搭配使用。这种方式实现起来比较简单方便,也非常适合利用在一些面向过程场景中。

// main.go
type EchoInterface interface {Echo() string
}

type Echoer struct {
}

func (e Echoer) Echo() string {return"hello world!"}

func Echo(e EchoInterface) string {return e.Echo()
}

func main() {provider := Echoer{}
	fmt.Println(Echo(provider))
}

测试文件:

// main_test.go
type FakeEchoer struct {
}

func (f FakeEchoer) Echo() string {return"mocked!"}

func TestFunc(t *testing.T) {Convey("test", t, func() {provider := FakeEchoer{}
		So(Echo(provider), ShouldEqual, "mocked!")
	})
}

总结

上面的几种测试方法基本上是通过固定的原型将代码转为测试友好的 Golang 代码。这样可以通过 Mock,减少来自于其他数据和前置条件的影响,尽可能的降低代码开发的附加成本。

Built with Hugo
主题 StackJimmy 设计