写Golang UT的几种方法

native way

Posted by KL on September 13, 2018

怎么样在Golang里面写UT

UT test是测试中的最小测试单元,一般而言,需要fake 输入,compare预期输出,最理想或者最简单的情况是输入特别易于fake,看下面的例子 建设我们有一个实现➕1功能的函数 sum.go

// sum.go
package sum

func addOne(a int) int {
	return a + 1
}

如果你要为这个函数写UT,你需要创建一个测试文件 sum_test.go,这样当你运行命令 go test xxx时, go 会自动运行package下面的所有的 *_test.go

// sum_test.go
package sum

import (
	"errors"
	"testing"
)

func TestAddOne(t *testing.T) {// function name must bu Upper case, or it will not be run
	if addOne(3) != 4 {
		t.Errorf("the expect result should be 4, but got %v", addOne(3))
		return 
	}
}

UT的难点在于input的fake,在真实项目中,很少有函数的输入会上上例中的一样简单,在大一点的项目中,肯定会用到函数的组装,流程控制,外部包,系统函数的引用等等,对于复杂输入的情况,怎么样写UT呢?

流程控制函数的mock

我们看一个例子

// car.go
package car

type Car struct {
}

func NewCar() *Car {
	return &Car{}
}

func (c *Car) start() error {
	return nil
}

func (c *Car) stop() error {
	return nil
}

func run(car *Car) error {
	if err := car.assemble(); err != nil {
		return err
	}

	if err := car.start();  err != nil {
		return err
	}

	if err := car.stop();  err != nil {
		return err
	}
}

func main() {
	car := NewCar()
	run(car)
}

在上例中, run 组合了三个函数,通常UT的做法就是让 run函数接收一个 interface类型的参数,而不是一种具体的实现类型,让我们按照这个思路改动下

type RunBehavior interface {
	start() error
	stop() error
}

func run(r RunBehavior) {
	if err := r.start();  err != nil {
		return err
	}

	if err := r.stop();  err != nil {
		return err
	}
}

func main() {
	car := NewCar()
	run(car)
}

经过改动之后, run 的输入参数是一个接口 RunBehavior, 然后我们看怎么样对 run 进行UT

// car_test.go
package car

import "fmt"

type testCar {
	startErr error
	stopErr error
}

func (c *Car) start() error {
	return c.startErr
}

func (c *Car) stop() error {
	return c.stopErr
}

func TestRun(t *testing.T) {
	// start error
	car := &testCar{startErr: fmt.Errorf("start error, exit")}
	if err := run(car); err == nil || err.Error() != "start error, exit" {
		t.Errorf("xxx")
		return
	}
	// start error
	car := &testCar{stopErr: fmt.Errorf("stop error, exit")}
	if err := run(car); err == nil || err.Error() != "stop error, exit" {
		t.Errorf("xxx")
		return
	}
	// ok
	car := &testCar{}
	if err := run(car); err != nil {
		t.Errorf("xxx")
		return
	}
}

从上例子中能看到,我们创建了一个 testCar 的实现,然后对 run 进行测试的时候,就可以很容易的 run 函数中的每一步流程进行控制,你可以得到任意输想要的每一步返回结果

系统调用的mock

// read.go
import "os"

func read(path string) error {
	f, err := os.Open("notes.txt")
	if err != nil {
	    return err
	}
	return nil
}

如果要对 read 函数做测试,并没有太好的办法,通常有两种做法,一种是 把 os.OpenFile 做成一个参数

var osOpen = os.Open

func read(path string) error {
	f, err := osOpen("notes.txt")
	if err != nil {
	    return err
	}
	return nil
}

测试的时候定义自己的函数去替换系统函数

    oldOsOpen := osOpen
    defer func() { osOpen = oldOsOpen }()

    myOpen := func(path string)(*os.File, error) {
        return nil, nil
    }
    osOpen = myOpen

这种做法是hack的做法,比较出错,需要用到全局变量,但是确实可以把一些不能测到的函数变得可测试,当然也可以使用接口的方法,让 read 函数接收一个 io.Reader interface, 这种方法就好得多。

函数作为参数

还有一种比较改进的做法是把函数作为参数放到参数里面

type OpenFunc func(string) (*File, error)
func read(fn OpenFunc, path string) error {
	f, err := fn(path)
	if err != nil {
	    return err
	}
	return nil
}

通过把函数作为参数,也可以很容易的mock掉函数调用函数的情况,但是这种做法适用于函数内调用函数的数量比较少,否则你就需要传递大量的函数参数作为输入,这样代码写出来也很丑陋。

小结

golang原生UT的方法就是尽量使用接口作为参数,尽量不引入比较heavy的测试框架, 但是 sqlmock 此类查询调用的没有太好的办法,还是乖乖的使用第三包比较省心。golang的UT确实没有python的好写,python支持的各种高级mock,可以让你任意替换掉函数内的任意函数的返回值,虽然这样易于UT,但是这也是双刃剑。像golang这样的UT策略,对代码实现的组织和设计要求比较高,否则代码根本就没办法UT🤣。