我想大多数人都会同意,测试是开发软件的一个重要部分。然而,编写测试并不总是令人愉快的。有些东西,比如纯函数,是测试的天堂,我们应该尽可能地努力编写容易测试的代码。
对于有时不可避免的情况,我们必须测试依赖于外部包的代码,或者仅仅是在孤立的单元测试中不能正常工作的东西,模拟可以起到很大的帮助。
这些 "测试替身 "使我们能够对代码进行单元测试,例如,尝试连接到数据库或对外界进行HTTP调用,通过假装上述动作发生,并断言我们其余的假设成立。
当然,这不是一个完美的解决方案,如上所述,应该尽可能避免,但有时我们无法做到。无论如何,在依赖关系被注入的情况下,使用接口通常是个好主意,因为否则在我们的测试中很难用一个模拟来替换恶意的依赖关系。这样做还有一个好处,那就是如果我们愿意的话,以后可以把这个依赖的具体实现换成另一个满足相同接口的实现。
现在在Go中(和其他许多语言一样),有几个模拟框架可用,如gomock、testify和其他一些框架。它们工作得很好,而且通常(由于Go的反射功能目前的限制)涉及到为要模拟的接口生成存根。
由于多年来我在不同的语言中使用过许多不同的嘲讽框架,我想尝试一些新的东西,为我正在测试的一个小程序编写一个非常简单的嘲讽工具,这样我就不需要为测试而增加另一个依赖。这个小工具将在接下来的几段中描述--它是相当有限的,不会很好地概括许多更高级的问题。
通常嘲讽框架还包括断言哪些参数被传入存根函数调用的方法,以及其他许多花哨的东西。这里的目标只是创建一个机制来存根出一个接口的方法,并有一个很好的方法来声明这个接口的方法在随后的调用中应该返回什么,以便能够测试多个成功和失败的案例,而不需要为同一个方法写几个假的版本。
在下面的例子中,有一个叫做DataSource 的接口,它有一个叫做GetProducts 的方法。
type DataSource interface {
GetProducts() ([]*model.Product, error)
}
type ProductFetcher struct {
DataSource DataSource
}
func (f *ProductFetcher) Execute() (int, error) {
...fetch and count products...
}
还有一个ProductFetcher 类型,它有一个Execute 方法。这个ProductFetcher ,它有一个DataSource ,用来从数据库中获取数据。不幸的是,这个DataSource 的具体实现实际上是连接到一个数据库,而这个数据库在测试运行期间是不存在的,所以我们需要把它模拟出来。
我们希望能够写出类似于下面的测试。
func TestFetchProductsSuccess(t *testing.T) {
var prods []*model.Product
prods = append(prods, &model.Product{Code: "123"})
prods = append(prods, &model.Product{Code: "234"})
exps := make(Expectations)
exps.Add("GetProducts", nil, prods)
fetcher := ProductFetcher{
DataSource: &mock.DataSource{Expectations: exps},
}
numProducts, err := fetcher.Execute()
if numProducts != 2 || err != nil {
t.Errorf("Error, actual: %v expected: %v", numProducts, 2)
return
}
}
func TestFetchProductsFailure(t *testing.T) {
var prods []*model.Product
exps := make(Expectations)
exps.Add("GetProducts", prods, errors.New("err"))
c := ExportCommand{
DataSource: &mock.DataSource{Expectations: exps},
}
_, err := c.Execute()
expected := "err"
if err == nil || err.Error() != expected {
t.Errorf("Error, actual: %v expected: %v", err.Error(), expected)
return
}
}
在这些测试中,我们假设在第一种情况下,DataSource 成功地返回两个产品,而在第二种情况下返回一个错误。我们通过使用exps.Add ,告诉我们的mock应该为GetProducts 方法返回什么来做到这一点。
为此,我们创建了一个Expectations 类型,它是一个函数名称与Expectation 的映射。
type Expectations map[string]*Expectation
type Expectation struct {
CallCount int
DefaultReturn interface{}
ReturnValues []interface{}
}
这样,我们就可以将一个函数名映射到所需的返回值。也可以为每个函数添加多个返回值,以备一个方法被多次调用,还有DefaultReturn 值,这是在应该返回错误而另一个返回值不能用的情况下使用。
然后我们需要一种方法来将这些期望值添加到模拟对象中。
func (e Expectations) Add(fn string, def interface{}, retValues ...interface{}) {
exp := e[fn]
if exp == nil {
exp = &Expectation{}
}
exp.DefaultReturn = def
exp.ReturnValues = append(exp.ReturnValues, retValues...)
e[fn] = exp
}
基本上,我们只需将给定的返回值添加到fn's map entry(如果它还不存在,就会被创建)。
这个练习的重点是,我们不必为每一对不同的返回值存根出接口方法,所以我们需要一种方法让这些存根访问当前调用的Expectations 。
func (e Expectations) Return(fn string) (interface{}, error) {
return e[fn].Return()
}
func (e *Expectation) Return() (interface{}, error) {
res := e.ReturnValues[e.CallCount]
var err error
errTyped, ok := res.(error)
if ok {
err = errTyped
res = e.DefaultReturn
}
e.CallCount = e.CallCount + 1
return res, err
}
正如你所看到的,这个Return 方法只适用于有1或2个返回值的方法,但如果我们需要的话,可以简单地将其泛化,增加另一个Return3 版本,或者使用一个返回值的数组。
除此之外,我们只是为当前的CallCount ,增加所述的计数,并处理我们有一个错误和需要为其他返回值提供一个合理的空值的情况。
现在我们需要做的就是创建接口存根。因为有了这个漂亮的小工具Expectations ,我们只需要创建一个存根,然后就可以在不同的调用中重复使用。
type DataSource struct {
Expectations Expectations
}
func (d *DataSource) GetProducts() ([]*model.Product, error) {
v, err := d.Expectations.Return("GetProducts")
return v.([]*model.Product), err
}
GetProducts 方法简单地调用Expectations' Return 方法,对产品数组进行类型确认,并返回所有内容。
完成了!
现在我们也可以在其他的接口上重新使用这个工具--我们只需要为所有的接口方法创建这些双线的存根,就可以了。
结论
当然,这个小东西在处理一个巨大的代码库和所有可以想象到的边缘情况时不会有什么帮助,但对于像我为之建立的这个小项目来说,它可能足够强大,而且由于它的简单性,很容易扩展。
在扩展方面,增加对检查存根方法调用的输入参数的支持,或者启用可变数量的返回值,都是微不足道的。
更多的高级功能涉及并发或使用reflect ,自己做也是可行的,但在某一点上,使用像testify这样的经过战斗考验的框架会更有效率。
在我看来,这是一个有趣的小练习,它解开了我用了很久的嘲讽框架背后的魔力,这是我认为既愉快又重要的事情。