实现一组 Restful API,读取 Mongodb profile,计算一年内每天,每周,每月的平均响应时间并返回 JSON 格式数据。
我是使用gin来实现的,功能不难,但是在再unit test的时候还遇到了一些槛,我发现自己的写法很难做Mock,于是做了大量的搜索,希望能用很符合go语言风格的方式,解决这个问题。找到的其中一篇文章是《You're Not Using This Enough, Part One: Go Interfaces》,原文是英文的,于是想通过翻译这篇文章,做一些分享。下面是原文内容:
Go语言正变的越来越流行,但是由于一些开发者在不断的追问为什么还没有泛型给新人造成了一些困扰,事实上很多开发者不使用泛型也很好的解决了问题。基于我近Go语言方面的尝试,希望和你们分享一下我提高代码灵活性和测试性的解决方案。
我的的建议是尽可能多的使用Interface,特别是当我在写自动化测试Go代码时,当我发现struct变得越来越大越重要时,就是时候拆分成interface了。
下面将从下面两个方面开始本文:
Fast Interface Review
首先定义一个有以下方法的interface:
type Config interface {
Get(key string) (string, error)
Set(key, val string) (error)
}
然后定义一个struct满足这个interface的“契约”,仅仅实现这个interface的所有方法:
type InmemConfig struct {
M map[string]string
}
func (c InmemConfig) Get(key string) (string, error) {
val, ok := c.M[key]
if ok {
return val, nil
} else {
return "", errors.New("Tried to get a key which doesn't exist")
}
}
func (c InmemConfig) Set(key, val string) error {
c.M[key] = val
return nil
}
这是一种非常灵活的方式,特别是你想通过一个通用的inteface写代码隐藏不同的“后端”,或者一个需要通过mock来测试的component。
Testing with Interfaces
Go并不像其它你熟悉的语言(Java,PHP)那样通过Mock来测试,它建议开发者通过定义新的Struct实现细粒度的interface来完成测试。
下面的这个程序,通过扩展上面的代码,实现了一个服务器,响应request并返回在命令行指定header(可选的).
config.go:
package main
import (
"errors"
"fmt"
"log"
"net/http"
"os"
)
type Config interface {
Get(key string) (string, error)
Set(key, val string) error
}
var (
Cfg InmemConfig
)
type InmemConfig struct {
M map[string]string
}
type Responder struct {
Cfg Config
}
func (c InmemConfig) Get(key string) (string, error) {
val, ok := c.M[key]
if ok {
return val, nil
} else {
return "", errors.New("Tried to get a key which doesn't exist")
}
}
func (c InmemConfig) Set(key, val string) error {
c.M[key] = val
return nil
}
func (r *Responder) Handler(w http.ResponseWriter, req *http.Request) {
cfgOption, err := r.Cfg.Get("Option.Header")
if err != nil {
log.Fatal(err)
}
w.Header().Set("X-Config-Option", cfgOption)
fmt.Fprintf(w, "This is the response body!")
}
func main() {
responder := Responder{
Cfg: InmemConfig{
M: make(map[string]string),
},
}
if err := responder.Cfg.Set("Option.Header", os.Args[1]); err != nil {
log.Fatal(err)
}
http.HandleFunc("/", responder.Handler)
http.ListenAndServe(":8080", nil)
}
运行方法如下:
$ go run file.go MagicTokenOfMagic
看,返回了在运行时指定的header~
Config接口很简单,但是在实现方面很灵活。你可以发挥创造力来实现这个接口完成存取配置信息的功能,所有的实现均可以在指定使用这个接口的地方使用,这种机制可以加强代码的复用。例如,这些数据可以存储在Consul或者etcd上,于是在集群中的任何一台机器都可以访问这些信息。再或者这些数据可以加密后存储在硬盘上,只要实现interface里定义的“契约”,程序就可以使用这个Config,而无须关心实现的细节。
这种与无关实现细节的特性也非常有助于测试,通常你在测试一段代码的时候是不会运行所有依赖的代码的。与上面的程序为例:如果我们想测试那个http的handler,不会真的创建一个InmemConfirg,并从命令行传入设置参数。我们完全的fake这部分,因为当前的测试不关心配置实现的细节。
另外一点是w实现了Go标准库里的http.ResponseWriter interface,需要的话,我们可以利用这点更细粒度的控制它的行为。
上述这些特性,可以开发者更有效的测试代码,从而交付高质量的软件。
下现的就是测试这个handler的代码,我们创建了一个实现了我们定义的interface的FakeConfig,也创建了FakeResponseWriter用于替代标准库里的输出行为。
config_test.go:
package main
import (
"net/http"
"testing"
)
type FakeConfig struct{}
type FakeResponseWriter struct {
h http.Header
Body []byte
}
const (
msg = "Please send help, I'm trapped in the web server"
)
func (c FakeConfig) Get(key string) (string, error) {
return msg, nil
}
// It always works! Nice
func (c FakeConfig) Set(key, val string) error {
return nil
}
func (wr FakeResponseWriter) Header() http.Header {
return wr.h
}
func (wr FakeResponseWriter) Write(b []byte) (int, error) {
wr.Body = b
return len(msg), nil
}
func (wr FakeResponseWriter) WriteHeader(i int) {}
func TestResponderHandler(t *testing.T) {
responder := Responder{
Cfg: FakeConfig{},
}
w := FakeResponseWriter{
h: http.Header{},
}
// Don't even care about the request!
// But if needed to we could control that pretty well too.
responder.Handler(w, nil)
header := w.Header().Get("X-Config-Option")
if header != msg {
t.Fatalf("Expected X-Config-Option to be %q, got %q", msg, header)
}
}
从上面的代码可见我们可以控制interface,像搭乐高玩具一样来测试程序。
这种风格的测试方式会鼓励开发者快速迭代的创建并修改单元测试,从而提高代码覆盖率。我喜欢的方面是一但建立起“mock”的interface,你会很快的抽取出你要测试的地方,根据不同的场景fake不同的实现也是相当容易的。
补充:
关于使用interface来提高Go代码的可测试性,原文作者还写了第二篇文章《Interfaces and Composition for Effective Unit Testing in Golang》,这也是我下一次想翻译的文章啦。