测试替身(test double)是一种能够让单元测试用例变得更为独立的常用方法。当测试不方便使用实际的对象、结构或者函数时,我们就可以使用测试替身来模拟它们。因为测试替身能够提高被测试代码的独立性,所以自动单元测试环境经常会使用这种技术。
测试邮件发送代码是一个需要使用测试替身的场景:很自然地,你并不希望在进行单元测试时发送真正的邮件,而解决这个问题的一种方法,就是创建出能够模拟邮件发送操作的测试替身。同样地,为了对简单Web服务进行单元测试,我们需要创建出一些测试替身,并通过这些替身移除单元测试用例对真实数据库的依赖。
测试替身的概念非常直观易懂——程序员要做的就是在进行自动测试时,创建出测试替身并使用它们去代替实际的函数或者结构。然而问题在于,使用测试替身需要在编码之前进行相应的设计:如果你在设计程序时根本没有考虑过使用测试替身,那么你很可能无法在实际测试中使用这一技术。比如,上一节展示的简单Web服务的设计就无法在测试中创建测试替身,这是因为对数据库的依赖已经深深地扎根于这些代码之中了。
实现测试替身的一种设计方法是使用依赖注入(depen-dency injection)设计模式。这种模式通过向被调用的对象、结构或者函数传入依赖关系,然后由依赖关系代替被调用者执行实际的操作,以此来解耦软件中的两个或多个层(layer),而在Go语言当中,被传入的依赖关系通常会是一种接口类型。接下来,就让我们来看看,如何在第7章介绍的简单Web服务中使用依赖注入设计模式。
使用Go实现依赖注入
在第7章介绍的简单Web服务中,handleRequest
处理器函数会将GET请求转发给handleGet
函数,后者会从URL中提取文章的ID,然后通过data.go
文件中的retrieve
函数获取与文章ID相对应的Post
结构。当retrieve
函数被调用时,它会使用全局的sql.DB
结构去打开一个连接至PostgreSQL的数据库连接,并在posts
表中查找指定的数据。
图8-3展示了简单Web服务在处理GET请求时的函数调用流程。除retrieve
函数需要通过全局的sql.DB
实例访问数据库之外,访问数据库对于其他函数来说都是透明的(transparent)。
图8-3 简单Web服务在处理GET请求时的函数调用流程图
正如图8-3所示,handleRequest
和handleGet
都依赖于retrieve
函数,而后者最终又依赖于sql.DB
。因为对sql.DB
的依赖是整个问题的根源,所以我们必须将其移除。
跟很多问题一样,解耦依赖关系也存在着好几种不同的方式:既可以从底部开始,对数据抽象层的依赖关系进行解耦,然后直接获取sql.DB
结构,也可以从顶部开始,将sql.DB
注入到handleRequest
当中。本节要介绍的是后一种方法,也就是以自顶向下的方式解耦依赖关系的方法。
图8-4展示了移除对sql.DB
的依赖并将这种依赖通过主程序注入函数调用流程中的具体方法。注意,问题的关键并不是避免使用sql.DB
,而是避免对它的直接依赖,这样我们才能够在测试时使用测试替身。
图8-4 将一个包含sql.DB
的Post
结构传递到函数调用流程中,以此来对简单Web服务实现依赖注入模式。因为Post
结构已经包含了sql.DB
,所以调用流程中的所有函数都不再依赖sql.DB
正如前面所说,为了解耦被调用函数对sql.DB
的依赖,我们可以将sql.DB
注入handleRequest
,但是把sql.DB
实例或者指向sql.DB
的指针作为参数传递给handleRequest
对解决问题是没有任何帮助的,因为这样做只不过是将问题推给了控制流的上游。作为替代,我们需要将代码清单8-12所示的Text
接口传递给handleRequest
。当测试程序需要从数据库里面获取一篇文章时,它可以调用Text
接口的方法,并假设这个方法知道自己应该做什么以及应该返回什么数据。
代码清单8-12 传递给handleRequest
的接口
type Text interface {
fetch(id int) (err error)
create() (err error)
update() (err error)
delete() (err error)
}
接下来,我们要做的就是让Post
结构实现Text
接口,并将它的一个字段设置成一个指向sql.DB
的指针。为了让Post
结构实现Text
接口,我们需要让Post
结构实现Text
接口拥有的所有方法,不过由于代码清单8-12中定义的Text
接口原本就是根据Post
结构拥有的方法定义而来的,所以Post
结构实际上已经实现了Text
接口。代码清单8-13展示了添加新字段之后的Post
结构。
代码清单8-13 添加了新字段之后的Post
结构
type Post struct {
Db *sql.DB
Id int `json:"id"`
Content string `json:"content"`
Author string `json:"author"`
}
这种做法解决了将sql.DB
直接传递给handleRequest
的问题:程序并不需要将sql.DB
传递给被调用的函数,它只需要和之前一样,向被调用的函数传递Post
结构的实例即可,而Post
结构的各个方法也会使用结构内部的sql.DB
指针来代替原本对全局变量的访问。因为handleRequest
函数还是和以前一样,接受Post
结构作为参数,所以它的签名不需要做任何修改。在根据新的Post
结构做相应的修改之后,handleRequest
函数如代码清单8-14所示。
代码清单8-14 修改后的handleRequest
函数
func handleRequest(t Text) http.HandlerFunc {// 传入Text接口
return func(w http.ResponseWriter, r *http.Request) {// 返回带有正确签名的函数
var err error
switch r.Method {
case "GET":
err = handleGet(w, r, t) // 将Text接口传递给实际的处理器
case "POST":
err = handlePost(w, r, t)
case "PUT":
err = handlePut(w, r, t)
case "DELETE":
err = handleDelete(w, r, t)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
正如代码所示,因为handleRequest
函数已经不再遵循ServeHTTP
方法的签名规则,所以它已经不再是一个处理器函数了。这使我们无法再使用HandleFunc
函数把它与一个URL绑定在一起了。
为了解决这个问题,程序再次使用了本书第3章中介绍过的处理器串联技术,让handleRequest
返回了一个http.HandlerFunc
函数。
之后,程序在main
函数里面将不再绑定handleRequest
函数到URL,而是直接调用handleRequest
函数,让它返回一个http.HandleFunc
函数。因为被返回的函数符合HandleFunc
方法的签名要求,所以程序就可以像之前一样,把它用作处理器并与指定的URL进行绑定。代码清单8-15展示了修改后的main
函数。
代码清单8-15 修改后的main
函数
func main() {
var err error
db, err := sql.Open("postgres", "user=gwp dbname=gwp password=gwp sslmode=disable")
if err != nil {
panic(err)
}
server := http.Server{
Addr: ":8080",
}
http.HandleFunc("/post/", handleRequest(&Post{Db: db}))
//将Post结构传递给handleRequest函数,然后绑定函数返回的处理器
server.ListenAndServe()
}
注意,程序通过Post
结构,以间接的方式将指向sql.DB
的指针传递给了handleRequest
函数,这就是将依赖关系注入handleRequest
的方法。代码清单8-16展示了同样的依赖关系是如何被注入handleGet
函数的。
代码清单8-16 修改后的handleGet
函数
func handleGet(w http.ResponseWriter, r *http.Request, post Text) (err error) {//接受Text接口作为参数
id, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
return
}
err = post.fetch(id)//获取数据并将其存储到Post结构
if err != nil {
return
}
output, err := json.MarshalIndent(post, "", "\t\t")
if err != nil {
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(output)
return
}
修改后的handleGet
函数跟之前差不多,主要区别在于现在的handleGet
函数将直接接受Post
结构,而不是像以前那样在内部创建Post
结构。除此之外,handleGet
函数现在会通过调用Post
结构的fetch
方法来获取数据,而不必再调用需要访问全局sql.DB
实例的retrieve
函数。代码清单8-17展示了Post
结构的fetch
方法的具体定义。
代码清单8-17 新的fetch方法
func (post *Post) fetch(id int) (err error) {
err = post.Db.QueryRow("select id, content, author from posts where id =$1", id)
.Scan(&post.Id, &post.Content, &post.Author)
return
}
这个fetch
方法在访问数据库时不需要使用全局的sql.DB
结构,而是使用被传入的Post
结构的Db
字段来访问数据库。如果我们现在编译并运行修改后的简单Web服务,那么它将和修改之前的简单Web服务一样正常工作。不同的地方在于,修改后的代码已经移除了对全局的sql.DB
结构的依赖。
只要对数据库的依赖还深埋在代码之中,我们就无法对其进行独立的测试。为此,我们在上面花了不少功夫来移除代码中的依赖,从而使单元测试用例可以变得更为独立。在通过外部代码实现依赖注入之后,我们接下来就可以使用测试替身对程序进行测试了。
因为handleRequest
函数能够接受任何实现了Text
接口的结构,所以我们可以创建出一个实现了Text
接口的测试替身,并把它作为传递给handleRequest
函数的参数。代码清单8-18展示了一个名为FakePost
的测试替身,以及它为了满足Text
接口的要求而实现的几个方法。
代码清单8-18 FakePost测试替身
package main
type FakePost struct {
Id int
Content string
Author string
}
func (post *FakePost) fetch(id int) (err error) {
post.Id = id
return
}
func (post *FakePost) create() (err error) {
return
}
func (post *FakePost) update() (err error) {
return
}
func (post *FakePost) delete() (err error) {
return
}
注意,为了进行测试,fetch
方法会把所有传递给它的ID都设置为FakePost
结构的ID。此外,虽然FakePost
结构的其他方法在测试时都不会用到,但是为了满足Text
接口的实现要求,程序还是为每个方法都定义了一个没有任何实际用途的空方法。为了保持代码的清晰,这些测试替身代码被放到了doubles.go
文件里面。
接下来,我们还需要在server_test.go
文件里为handleGet
函数加上代码清单8-19所示的测试用例。
代码清单8-19 将测试替身依赖注入到handleRequest
func TestHandleGet(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/post/", handleRequest(&FakePost{}))//传入一个FakePost结构来代替Post结构
writer := httptest.NewRecorder()
request, _ := http.NewRequest("GET", "/post/1", nil)
mux.ServeHTTP(writer, request)
if writer.Code != 200 {
t.Errorf("Response code is %v", writer.Code)
}
var post Post
json.Unmarshal(writer.Body.Bytes(), &post)
if post.Id != 1 {
t.Errorf("Cannot retrieve JSON post")
}
}
测试用例现在不再向handleRequest
传递Post
结构,而是传递一个FakePost
结构,这个结构就是handleRequest
所需的一切。除此之外,这个测试用例跟之前的测试用例没有什么不同。
为了验证测试替身是否能正常工作,我们可以在关闭数据库之后再次运行测试用例。在这种情况下,旧的测试用例将会因为无法连接数据库而失败,而使用了测试替身的测试用例则因为不需要实际的数据库而一切如常进行。这也意味着我们在辛苦了这么久之后,终于可以独立地测试handleGet
函数了。
跟之前的测试一样,如果handleGet
函数运作正常,那么测试就会通过;否则,测试就会失败。需要注意的是,这个测试用例并没有实际测试Post
结构的fetch
方法,这是因为实施这种测试需要对posts
表执行预设操作和拆卸操作,而重复执行这种操作会在测试时耗费大量的时间。这样做的另一个好处是隔离了Web服务的各个部分,使程序员可以独立测试每个部分,并在发现问题时更准确地定位出错的部分。因为代码总是在不断地演进和变化当中,所以能够做到这一点是非常重要的。在代码不断衍化的过程中,我们必须保证后续添加的部分不会对前面已有的部分造成破坏。