第8章 应用测试

本章主要内容

测试是编程工作中非常重要的一环,但很多人却忽视了这一点,又或者只是把测试看作是一种可有可无的补充手段。Go语言提供了一些基本的测试功能,这些功能初看上去可能会显得非常原始,但正如本章将要介绍的那样,这些工具实际上已经能够满足程序员对自动测试的需要了。除了Go语言内置的testing 包之外,本章还会介绍checkGinkgo 这两个流行的Go测试包,它们提供的功能比testing 包更为丰富。

跟前面章节介绍过的Web应用编程库一样,Go语言的测试库也只提供了基本的工具,而程序员要做的就是在这些工具的基础上,构建出能够满足自己需求的测试。

Go的标准库提供了几个与测试有关的库,其中最主要的是testing 包,本章介绍的绝大部分测试功能都来源于这个包。net/http/httptest 包是另一个与Web应用编程有关的库,这个库是基于testing 库实现的。正如它的名字所示,httptest 包是一个用于测试Web应用的库。

因为testing 包提供了在Go中实现基本的自动测试的能力,所以本章会先介绍testing 包,等读者了解了testing 包之后,再学习httptest 包就会有事半功倍的效果。

testing 包需要与go test 命令以及源代码中所有以_test.go 后缀结尾的测试文件一同使用。尽管Go并没有强制要求,但一般来说,测试文件的名字都会与被测试源码文件的名字相对应。

举个例子,对于源码文件server.go ,我们可以创建出一个名为server_test.go 的测试文件,这个测试文件包含我们想对server.go 进行的所有测试。另外需要注意的一点是,被测试的源码文件和测试文件必须位于同一个包之内。

为了测试源代码,用户需要在测试文件中创建具有以下格式的测试函数,其中 Xxx 可以是任意英文字母以及数字的组合,但是首字符必须是大写的英文字母:

func TestXxx

(*testing.T) { ... }

在测试函数的内部,用户可以使用ErrorFail 等一系列方法表示测试失败。当用户在终端里面执行go test 命令的时候,所有符合上述格式的测试函数就会被执行。如果一个测试在执行时没有出现任何失败,那么我们就说函数通过了测试。接下来,就让我们实际地学习如何使用testing 包进行测试。

顾名思义,单元测试(unit test),就是一种为验证单元的正确性而设置的自动化测试,一个单元就是程序中的一个模块化部分。一般来说,一个单元通常会与程序中的一个函数或者一个方法相对应,但这并不是必须的。程序中的一个部分能否独立地进行测试,是评判这个部分能否被归纳为“单元”的一个重要指标。一个单元通常会接受数据作为输入并返回相应的输出,而单元测试用例要做的就是向单元传入数据,然后检查单元产生的输出是否符合预期。单元测试通常会以测试套件(test suite)的形式运行,后者是为了验证特定行为而创建的单元测试用例集合。

Go的单元测试会按照功能分组,并放置在以_test.go 为后缀的文件当中。作为例子,我们接下来要考虑的是如何对代码清单8-1所示的main.go文件 中的decode 函数进行测试。

代码清单8-1 一个JSON数据解码程序

package main

import (
 "encoding/json"
 "fmt"
 "os"
)

type Post struct {
 Id    int    `json:"id"`
 Content string  `json:"content"`
 Author  Author  `json:"author"`
 Comments []Comment `json:"comments"`
}

type Author struct {
 Id  int  `json:"id"`
 Name string `json:"name"`
}

type Comment struct {
 Id   int  `json:"id"`
 Content string `json:"content"`
 Author string `json:"author"`
}

func decode(filename string) (post Post, err error) {  ❶
  jsonFile, err := os.Open(filename)  ❶
 if err != nil {  ❶
  fmt.Println("Error opening JSON file:", err)  ❶
  return  ❶
 }  ❶
 defer jsonFile.Close()  ❶

 decoder := json.NewDecoder(jsonFile)  ❶
 err = decoder.Decode(&post)  ❶
 if err != nil {  ❶
  fmt.Println("Error decoding JSON:", err)  ❶
  return  ❶
 } ❶
 return ❶
} ❶

func main() { ❶
 _, err := decode("post.json")  ❶
 if err != nil {
  fmt.Println("Error:", err)
 }
}

❶ 将负责解码的代码重构到单独的解码函数中

这个程序复用了之前在代码清单7-8和代码清单7-9中展示过的JSON解码程序,但是它并没有像旧程序那样把所有逻辑都放到main 函数里面,而是将旧程序中负责打开文件并对其进行解码的部分重构到了单独的decode 函数里面,然后再在main 函数中调用decode 函数。需要注意的是,虽然程序员在大部分时间里关注的都是如何编写代码从而实现特性并交付功能,但写出可测试的代码同样也是非常重要的。为了做到这一点,程序员通常需要在编写程序之前对程序的设计进行思考,并把测试看作是软件开发的重要一环,本章稍后将对这一点进行更详细的说明。

代码清单8-2展示了我们将要解码的JSON文件,它跟第7章中被解码的JSON文件是完全一样的。

代码清单8-2 被解码的post.json 文件

{
 "id" : 1,
 "content" : "Hello World!",
 "author" : {
  "id" : 2,
  "name" : "Sau Sheong"
 },
 "comments" : [
  {
   "id" : 3,
   "content" : "Have a great day!",
   "author" : "Adam"
  },
  {
   "id" : 4,
   "content" : "How are you today?",
   "author" : "Betty"
  }
 ]
}

代码清单8-3展示了负责测试main.go文件main_test.go文件

代码清单8-3 对main.go 进行测试的main_test.go 文件

 package main ❶

import (
 "testing"
)

func TestDecode(t *testing.T) {
 post, err := decode("post.json")  ❷
 if err != nil {
  t.Error(err)
 }
 if post.Id != 1 {  ❸
  t.Error("Wrong id, was expecting 1 but got", post.Id)  ❸
 }  ❸
 if post.Content != "Hello World!" {  ❸
  t.Error("Wrong content, was expecting 'Hello World!' but got",
  ➥post.Content)
 }
}
func TestEncode(t *testing.T) {
 t.Skip("Skipping encoding for now")  ❹
}

❶ 测试文件与被测试的源代码文件位于同一个包内

❷ 调用被测试的函数

❸ 检查结果是否和预期的一样,如果不一样就显示一条出错信息

❹ 暂时跳过对编码函数的测试

这个测试文件与被测试的源码文件位于同一个包内,它唯一导入并使用的包为testing 包。函数TestDecode 是一个测试用例,它代表的是对decode 函数的单元测试。TestDecode 接受一个指向testing.T 结构的指针作为参数,该结构是testing 包中两个主要结构之一,当被测试函数的输出结果未如预期时,用户就可以使用这个结构来产生相应的失败(failure)以及错误(error)。

testing.T 结构拥有几个非常有用的函数:

除以上4个函数之外,testing.T 结构还提供了图8-1所示的一些便利函数(convenience function),这些便利函数都是由以上4个函数组合而成的。

08-01

图8-1 testing.T 结构提供的各个函数,每个格子都表示一个函数,其中位于白色格子内的函数为便利函数,它们由位于灰色格子内的函数组合而成。例如,Error 函数是Log 函数和Fail 函数的组合函数,它在被调用时,会先调用Log 函数,然后再调用Fail 函数

在图8-1中,组合函数Error 将会先后调用Log 函数和Fail 函数,而组合函数Fatal 则会先后调用Log 函数和FailNow 函数。

在测试函数TestDecode 内部,程序会正常地调用decode 函数,然后对函数返回的结果进行检查。如果函数返回的结果和预期的结果不一致,那么程序就可以根据情况调用FailFailNowErrorErrorf 或者Fatalf 等函数。正如之前所说,Fail 函数在把一个测试用例标记为“已失败”之后,会允许这个测试用例继续执行,但FailNow 函数则会更严格一些——它在把一个测试用例标记为“已失败”之后会立即退出,不再执行这个测试用例的剩余代码。无论是Fail 还是FailNow ,它们都只会对自己所处的测试用例产生影响,比如,在上面的例子中,TestDecode 调用的Error 函数就只会对TestDecode 本身产生影响。

为了运行TestDecode 测试用例,我们需要在测试文件main_test.go 所在的目录中执行以下命令:

go test

这条命令会执行当前目录中名字以_test.go 为后缀的所有文件。当我们在名为unit_testing 的目录中执行这个命令时,它将产生以下结果:

PASS
ok  unit_testing 0.004s

可惜的是,这个结果并没有给出多少有用的信息。为此,我们可以使用具体(verbose)标志-v 来获得更详细的信息,并通过覆盖率标志-cover 来获知测试用例对代码的覆盖率:

go test –v -cover

执行这条命令将得到以下结果:

=== RUN TestDecode
--- PASS: TestDecode (0.00s)
=== RUN TestEncode
--- SKIP: TestEncode (0.00s)
 main_test.go:23: Skipping encoding for now
PASS
coverage: 46.7% of statements
ok  unit_testing 0.004s

代码清单8-3在同一个测试文件里包含了两个测试用例,第一个是前面已经介绍过的TestDecode ,而另一个则是TestEncode 。因为代码清单8-1中的程序并未实现相应的编码方法,所以TestEncode 并没有做任何实际的行为。程序员在进行测试驱动开发(test-driven development, TDD)的时候,通常会让测试用例持续地失败,直到函数被真正地实现出来为止;但是,为了避免测试用例在函数尚未实现之前一直打印烦人的失败信息,用户也可以使用testing.T 结构提供的Skip 函数,暂时跳过指定的测试用例。此外,如果某个测试用例的执行时间非常长,我们也可以在实施完整性检查(sanity check)的时候,使用Skip 函数跳过该测试用例。

除了可以直接跳过整个测试用例,用户还可以通过向go test 命令传入短暂标志-short ,并在测试用例中使用某些条件逻辑来跳过测试中的指定部分。注意,这种做法跟在go test 命令中通过选项来选择性地执行指定的测试不一样:选择性执行只会执行指定的测试,并跳过其他所有测试,而-short 标志则会根据用户编写测试代码的方式,跳过测试中的指定部分或者跳过整个测试用例。

作为例子,让我们来看一下如何通过-short 标志来避免执行一个长时间运行的测试用例。首先,在main_test.go文件 中导入time 包,并创建一个新的测试用例:

func TestLongRunningTest(t *testing.T) {
 if testing.Short() {
  t.Skip("Skipping long-running test in short mode")
 }
 time.Sleep(10 * time.Second)
}

如果用户给定了-short 标志,测试用例TestLongRunningTest 将被跳过;相反,如果用户没有给定-short 标志,那么TestLongRunningTest 用例将被执行,并因此导致测试过程休眠10 s。现在,首先让我们来看一下测试用例在一般情况下是如何运行的:

=== RUN TestDecode
--- PASS: TestDecode (0.00s)
=== RUN TestEncode
--- SKIP: TestEncode (0.00s)
 main_test.go:24: Skipping encoding for now
=== RUN TestLongRunningTest
--- PASS: TestLongRunningTest (10.00s)
PASS
coverage: 46.7% of statements
ok  unit_testing 10.004s

正如我们所料,测试花了10 s来执行TestLongRunningTest 测试用例。现在,我们使用以下命令再次运行测试:

go test –v –cover -short

这次运行测试将得出以下结果:

=== RUN TestDecode
--- PASS: TestDecode (0.00s)
=== RUN TestEncode
--- SKIP: TestEncode (0.00s)
 main_test.go:24: Skipping encoding for now
=== RUN TestLongRunningTest
--- SKIP: TestLongRunningTest (0.00s)
 main_test.go:29: Skipping long-running test in short mode
PASS
coverage: 46.7% of statements
ok  unit_testing 0.004s

正如结果所示,长时间运行的测试用例TestLongRunningTest 在这次测试中被跳过了。

正如之前所说,单元测试的目的是独立地进行测试。尽管有些时候,测试套件会因为内部存在依赖关系而无法独立地进行单元测试,但是只要单元测试可以独立地进行,用户就可以通过并行地运行测试用例来提升测试的速度了,本节的内容将向我们展示如何在Go中实现这一点。

首先,在main_test.go文件 所在的目录中创建一个名为parallel_test.go 的文件,并在文件中键入代码清单8-4所示的代码。

代码清单8-4 并行测试

package main

import (
 "testing"
 "time"
)

func TestParallel_1(t *testing.T) { ❶
 t.Parallel() ❷
 time.Sleep(1 * time.Second)
}

func TestParallel_2(t *testing.T) { ❸
 t.Parallel()
 time.Sleep(2 * time.Second)
}

func TestParallel_3(t *testing.T) { ❹
 t.Parallel()
 time.Sleep(3 * time.Second)
}

❶ 模拟需要耗时一秒钟运行的任务

❷ 调用Parallel 函数,以并行方式运行测试用例

❸ 模拟需要耗时2 秒运行的任务

❹ 模拟需要耗时3 秒运行的任务

这个程序利用time.Sleep 函数,以3个测试用例分别模拟了3个需要耗时1s、2s和3s来运行的任务,并且为了让这些测试用例能够以并行的方式运行,程序还在每个测试用例的开头调用了testing.T 结构的Parallel 函数。

现在,我们只要在终端中执行以下命令,Go就会以并行的方式运行测试:

go test –v –short –parallel 3

这条命令中的并行标志-parallel 用于指示Go以并行方式运行测试用例,而参数3 则表示我们希望最多并行运行3个测试用例。另外,这条命令还使用了-short 标志,以便跳过main_test.go 测试文件中需要长时间运行的测试用例。以下是这个命令的执行结果:

=== RUN TestDecode
--- PASS: TestDecode (0.00s)
=== RUN TestEncode
--- SKIP: TestEncode (0.00s)
 main_test.go:24: Skipping encoding for now
=== RUN TestLongRunningTest
--- SKIP: TestLongRunningTest (0.00s)
 main_test.go:30: Skipping long-running test in short mode
=== RUN TestParallel_1
=== RUN TestParallel_2
=== RUN TestParallel_3
--- PASS: TestParallel_1 (1.00s)
--- PASS: TestParallel_2 (2.00s)
--- PASS: TestParallel_3 (3.00s)
PASS
ok  unit_testing 3.006s

从这个结果我们可以看到,main_test.go文件parallel_test.go文件 中的所有测试用例都被执行了,更为重要的是,parallel_test.go文件 中的3个并行测试用例被同时执行了:尽管这3个并行测试用例的运行时长各有不同,但由于它们是同时运行的,所以这3个测试用例最终都在运行时长最长的测试用例TestParallel_3 的执行过程中结束了,这也是整个测试最终耗费了3.006 s的原因——其中0.006 s用于执行main_test.go 中的前几个测试用例,而3 s则用于执行parallel_test.go 中运行时间最长的测试用例TestParallel_3

Go的testing 包支持两种类型的测试,一种是用于检验程序功能性的功能测试(functional testing),而另一种则是用于查明任务单元性能的基准测试(benchmarking)。在上一节学习过如何进行功能测试之后,这一节我们将要学习如何进行基准测试。

跟单元测试一样,基准测试用例也需要放置到以_test.go 为后缀的文件中,并且每个基准测试函数都需要符合以下格式:

func BenchmarkXxx(*testing.B) { ... }

作为例子,代码清单 8-5 展示了一个基准测试用例函数,这个函数定义在文件bench_test.go 里面。

代码清单8-5 基准测试

package main

import (
 "testing"
)

// benchmarking the decode function
func BenchmarkDecode(b *testing.B) {
 for i := 0; i < b.N; i++ {  ❶
  decode("post.json")
 }
}

❶ 循环执行解码函数,以便对其进行b.N 次基准测试

正如代码所示,在Go语言中进行基准测试是非常直观的:测试程序要做的就是将被测试的代码执行b.N 次,以便准确地检测出代码的响应时间,其中b.N 的值将根据被执行的代码而改变。比如,在上面展示的基准测试例子中,测试程序就将decode 函数执行了b.N 次。

为了运行基准测试用例,用户需要在执行go test 命令时使用基准测试标志-bench ,并将一个正则表达式用作该标志的参数,从而标识出自己想要运行的基准测试文件。当我们需要运行目录下的所有基准测试文件时,只需要把点(.) 用作-bench 标志的参数即可:

go test -v -cover -short –bench .

下面是这条命令的执行结果:

=== RUN TestDecode
--- PASS: TestDecode (0.00s)
=== RUN TestEncode
--- SKIP: TestEncode (0.00s)
main_test.go:38: Skipping encoding for now
=== RUN TestLongRunningTest
--- SKIP: TestLongRunningTest (0.00s)
main_test.go:44: Skipping long-running test in short mode
PASS
BenchmarkDecode 100000   19480 ns/op
coverage: 42.4% of statements
ok  unit_testing 2.243s

结果中的100000 为测试时b.N 的实际值,也就是函数被循环执行的次数。在这个例子中,迭代进行了10万次,并且每次耗费了19480 ns,即0.01948 ms。需要注意的是,在进行基准测试时,测试用例的迭代次数是由Go自行决定的,虽然用户可以通过限制基准测试的运行时间达到限制迭代次数的目的,但用户是无法直接指定迭代次数的——测试程序将进行足够多次的迭代,直到获得一个准确的测量值为止。在Go 1.5中,test 子命令拥有一个-test.count 标志,它可以让用户指定每个测试以及基准测试的运行次数,该标志的默认值为1。

注意,上面的命令既运行了基准测试,也运行了功能测试。如果需要,用户也可以通过运行标志-run 来忽略功能测试。-run 标志用于指定需要被执行的功能测试用例,如果用户把一个不存在的功能测试名字用作-run 标志的参数,那么所有功能测试都将被忽略。比如,如果我们执行以下命令:

go test -run x -bench .

那么由于我们的测试中不存在任何名字为x 的功能测试用例,因此所有功能测试都不会被运行。在只执行基准测试的情况下,go test 命令将产生以下结果:

PASS
BenchmarkDecode  100000    19714 ns/op
ok  unit_testing 2.150s

虽然检测单个函数的运行速度非常有用,但如果我们能够对比两个函数的运行速度,那么事情无疑会变得更加有意义!回想一下,我们在第7章曾经学过如何用两种不同的方法把JSON数据解封为结构:一种是使用Decode 函数,另一种则是使用Unmarshal 函数。因为上面的基准测试已经检测出了Decode 函数的运行速度,那么接下来就让我们检测一下Unmarshal 函数的运行速度吧。但是在进行基准测试之前,我们需要像代码清单8-6展示的那样,将解封操作的代码重构到main.go文件unmarshal 函数中。

代码清单8-6 解封JSON数据的函数

func unmarshal(filename string) (post Post, err error) {
 jsonFile, err := os.Open(filename)
 if err != nil {
  fmt.Println("Error opening JSON file:", err)
  return
 }
 defer jsonFile.Close()

 jsonData, err := ioutil.ReadAll(jsonFile)
 if err != nil {
  fmt.Println("Error reading JSON data:", err)
  return
 }
 json.Unmarshal(jsonData, &post)
 return
}

之后,我们还需要在基准测试文件bench_test.go 中添加代码清单8-7所示的基准测试用例,以便对u nmarshal函数进行基准测试。

代码清单8-7 对u nmarshal函数进行基准测试

func BenchmarkUnmarshal(b *testing.B) {
 for i := 0; i < b.N; i++ {
  unmarshal("post.json")
 }
}

一切准备就绪之后,再次运行基准测试命令,我们将得到以下结果:

PASS
BenchmarkDecode  100000    19577 ns/op
BenchmarkUnmarshal   50000    24532 ns/op
ok  unit_testing 3.628s

从上述结果可以看到,Decode 函数每次执行需要耗费0.019577 ms,而Unmarshal 函数每次执行需要耗费0.024532 ms,这说明Unmarshal 函数比Decode 函数慢了大约25%。

因为这是一本关于Web编程的书,所以我们除了要学习如何测试普通的Go程序,还需要学习如何测试Go Web应用。测试Go Web应用的方法有很多,但是在这一节中,我们只考虑如何使用Go对Web应用的处理器进行单元测试。

对Go Web应用的单元测试可以通过testing/httptest 包来完成。这个包提供了模拟一个Web服务器所需的设施,用户可以利用net/http 包中的客户端函数向这个服务器发送HTTP请求,然后获取模拟服务器返回的HTTP响应。

为了演示httptest 包的使用方法,我们会复用之前在7.14节展示过的简单Web服务。正如之前所说,这个简单Web服务只拥有一个名为handleRequest 的处理器,它会根据请求使用的HTTP方法,将请求多路复用到相应的处理器函数。举个例子,如果handleRequest 接收到的是一个HTTP GET 请求,那么它会把该请求多路复用到handleGet 函数,代码清单8-8展示了这两个函数的具体定义。

代码清单8-8 负责多路复用请求的处理器以及负责处理请求的GET 处理器函数

func handleRequest(w http.ResponseWriter, r *http.Request) {   ❶
 var err error
 switch r.Method { ❷
 case "GET":
  err = handleGet(w, r)
 case "POST":
  err = handlePost(w, r)
 case "PUT":
  err = handlePut(w, r)
 case "DELETE":
  err = handleDelete(w, r)
 }
 if err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
  return
 }
}

func handleGet(w http.ResponseWriter, r *http.Request) (err error) {
 id, err := strconv.Atoi(path.Base(r.URL.Path))
 if err != nil {
  return
 }
 post, err := retrieve(id)
 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
}

❶ handleRequest 将根据请求使用的HTTP 方法对其进行多路复用

❷ 根据请求使用的HTTP 方法,调用相应的处理器 函数

代码清单8-9展示了一个通过HTTP GET 请求对简单Web服务进行单元测试的例子,而图8-2则展示了这个程序的整个执行过程。

代码清单8-9 使用GET请求进行测试

package main

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "testing"
)

func TestHandleGet(t *testing.T) {
 mux := http.NewServeMux()  ❶
 mux.HandleFunc("/post/", handleRequest) ❷

 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.Error("Cannot retrieve JSON post")
 }
}

❶ 创建一个用于运行测试的多路复用器

❷ 绑定想要测试的处理器

❸ 创建记录器,用于获取服务器返回的HTTP 响应

❹ 为被测试的处理器创建相应的请求

❺ 向被测试的处理器发送请求

❻ 对记录器记载的响应结果进行检查

因为每个测试用例都会独立运行并启动各自独有的用于测试的Web服务器,所以程序需要创建一个多路复用器并将 handleRequest 处理器与其进行绑定。除此之外,为了获取服务器返回的HTTP响应,程序使用httptest.New Recorder函数创建了一个ResponseRecorder 结构,这个结构可以把响应存储起来以便进行后续的检查。

与此同时,程序还需要调用http.NewRequest 函数,并将请求使用的HTTP方法、被请求的URL以及可选的HTTP请求主体传递给该函数,从而创建一个HTTP请求(在第3章和第4章,我们讨论的是如何分析一个HTTP请求,而创建HTTP请求正好就是分析HTTP请求的逆操作)。

08-02

图8-2 使用Go的httptest 包进行HTTP测试的具体步骤

程序在创建出相应的记录器以及HTTP请求之后,就会使用ServeHTTP 把它们传递给多路复用器。多路复用器handleRequest 在接收到请求之后,就会把请求转发给handleGet 函数,然后由handleGet 函数对请求进行处理,并最终返回一个HTTP响应。跟一般服务器不同的是,模拟服务器的多路复用器不会把处理器返回的响应发送至浏览器,而是会把响应推入响应记录器里面,从而使测试程序可以在之后对响应的结果进行验证。测试程序最后的几行代码非常容易看懂,它们要做的就是对响应进行检查,看看处理器返回的结果是否跟预期的一样,并在出现意料之外的结果时,像普通的单元测试那样抛出一个错误。

因为这些操作看上去都非常简单,所以不妨让我们再来看另一个例子——代码清单8-10展示了如何为PUT 请求创建一个测试用例。

代码清单8-10 对PUT 请求进行测试

func TestHandlePut(t *testing.T) {
 mux := http.NewServeMux()
 mux.HandleFunc("/post/", handleRequest)

 writer := httptest.NewRecorder()
 json := strings.NewReader(`{"content":"Updated post","author":"Sau
 Sheong"}`)
 request, _ := http.NewRequest("PUT", "/post/1", json)
 mux.ServeHTTP(writer, request)

 if writer.Code != 200 {
  t.Errorf("Response code is %v", writer.Code)
 }
}

正如代码所示,这次的测试用例除了需要向请求传入JSON数据,跟之前展示的测试用例并没有什么特别大的不同。除此之外你可能会注意到,上述两个测试用例出现了一些完全相同的代码。为了保持代码的简洁性,我们可以把一些重复出现的测试代码以及其他测试夹具(fixture)代码放置到一个预设函数(setup function)里面,然后在运行测试之前执行这个函数。

Go的testing 包允许用户通过TestMain 函数,在进行测试时执行相应的预设(setup)操作或者拆卸(teardown)操作。一个典型的TestMain 函数看上去是下面这个样子的:

func TestMain(m *testing.M) {
 setUp()
 code := m.Run()
 tearDown()
 os.Exit(code)
}

setUp 函数和tearDown 函数分别定义了测试在预设阶段以及拆卸阶段需要执行的工作。需要注意的是,setUp 函数和tearDown 函数是为所有测试用例设置的,它们在整个测试过程中只会被执行一次,其中setUp 函数会在所有测试用例被执行之前执行,而tearDown 函数则会在所有测试用例都被执行完毕之后执行。至于测试程序中的各个测试用例,则由testing.M 结构的Run 方法负责调用,该方法在执行之后将返回一个退出码(exit code),用户可以把这个退出码传递给os.Exit 函数。

代码清单8-11展示了测试程序使用TestMain 函数之后的样子。

代码清单8-11 使用httptest 包的TestMain 函数

package main

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "os"
 "strings"
 "testing"
)

var mux *http.ServeMux
var writer *httptest.ResponseRecorder

func TestMain(m *testing.M) {
 setUp()
 code := m.Run()
 os.Exit(code)
}

func setUp() {
 mux = http.NewServeMux()
 mux.HandleFunc("/post/", handleRequest)
 writer = httptest.NewRecorder()
}

func TestHandleGet(t *testing.T) {
 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")
 }
}

func TestHandlePut(t *testing.T) {
 json := strings.NewReader(`{"content":"Updated post","author":"Sau
 Sheong"}`)
 request, _ := http.NewRequest("PUT", "/post/1", json)
 mux.ServeHTTP(writer, request)

 if writer.Code != 200 {
  t.Errorf("Response code is %v", writer.Code)
 }
}

更新后的测试程序把每个测试用例都会用到的全局变量放到了setUp 函数中,这一修改不仅让测试用例函数变得更加紧凑,而且还把所有与测试用例有关的预设操作都集中到了一起。但是,因为这个程序在测试之后不需要进行任何收尾工作,所以它没有配置相应的拆卸函数:当所有测试用例都运行完毕之后,测试程序就会直接退出。

上面展示的代码只测试了Web服务的多路复用器以及处理器,但它并没有测试Web服务的另一个重要部分。你也许还记得,在本书的第7章中,我们曾经从Web服务中抽离出了数据层,并将所有数据操作代码都放置到了data.go文件 中。因为测试handleGet 函数需要调用Post 结构的retrieve 函数,而测试handlePut 函数则需要调用Post 结构的retrieve 函数以及update 函数,所以上述测试程序在对简单Web服务进行单元测试时,实际上是在对数据库中的数据执行获取操作以及修改操作。

因为被测试的操作涉及依赖关系,所以上述单元测试实际上并不是独立进行的,为了解决这个问题,我们需要用到下一节介绍的技术。

测试替身 (test double)是一种能够让单元测试用例变得更为独立的常用方法。当测试不方便使用实际的对象、结构或者函数时,我们就可以使用测试替身来模拟它们。因为测试替身能够提高被测试代码的独立性,所以自动单元测试环境经常会使用这种技术。

测试邮件发送代码是一个需要使用测试替身的场景:很自然地,你并不希望在进行单元测试时发送真正的邮件,而解决这个问题的一种方法,就是创建出能够模拟邮件发送操作的测试替身。同样地,为了对简单Web服务进行单元测试,我们需要创建出一些测试替身,并通过这些替身移除单元测试用例对真实数据库的依赖。

测试替身的概念非常直观易懂——程序员要做的就是在进行自动测试时,创建出测试替身并使用它们去代替实际的函数或者结构。然而问题在于,使用测试替身需要在编码之前进行相应的设计:如果你在设计程序时根本没有考虑过使用测试替身,那么你很可能无法在实际测试中使用这一技术。比如,上一节展示的简单Web服务的设计就无法在测试中创建测试替身,这是因为对数据库的依赖已经深深地扎根于这些代码之中了。

实现测试替身的一种设计方法是使用依赖注入 (dependency injection)设计模式。这种模式通过向被调用的对象、结构或者函数传入依赖关系 ,然后由依赖关系代替被调用者执行实际的操作,以此来解耦软件中的两个或多个层(layer),而在Go语言当中,被传入的依赖关系通常会是一种接口类型。接下来,就让我们来看看,如何在第7章介绍的简单Web服务中使用依赖注入设计模式。

在第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)。

08-03

图8-3 简单Web服务在处理GET 请求时的函数调用流程图

正如图8-3所示,handleRequesthandleGet 都依赖于retrieve 函数,而后者最终又依赖于sql.DB 。因为对sql.DB 的依赖是整个问题的根源,所以我们必须将其移除。

跟很多问题一样,解耦依赖关系也存在着好几种不同的方式:既可以从底部开始,对数据抽象层的依赖关系进行解耦,然后直接获取sql.DB 结构,也可以从顶部开始,将sql.DB 注入到handleRequest 当中。本节要介绍的是后一种方法,也就是以自顶向下的方式解耦依赖关系的方法。

图8-4展示了移除对sql.DB 的依赖并将这种依赖通过主程序注入函数调用流程中的具体方法。注意,问题的关键并不是避免使用sql.DB ,而是避免对它的直接依赖,这样我们才能够在测试时使用测试替身。

08-04

图8-4 将一个包含sql.DBPost 结构传递到函数调用流程中,以此来对简单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 { ❶
  return func(w http.ResponseWriter, r *http.Request) { ❷
  var err error
  switch r.Method {
  case "GET":
   err = handleGet(w, r, t) ❸
  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
  }
 }
}

❶ 传入Text 接口

❷ 返回带有正确签名的函数

❸ 将Text接口传递给实际的处理器

正如代码所示,因为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})) ❶
 server.ListenAndServe()
}

❶ 将Post 结构传递给handleRequest函数,然后绑定函数返回的处理器

注意,程序通过Post 结构,以间接的方式将指向sql.DB 的指针传递给了handleRequest 函数,这就是将依赖关系注入handleRequest 的方法。代码清单8-16展示了同样的依赖关系是如何被注入handleGet 函数的。

代码清单8-16 修改后的handleGet 函数

func handleGet(w http.ResponseWriter, r *http.Request, post Text) (err error) { ❶
 id, err := strconv.Atoi(path.Base(r.URL.Path))
 if err != nil {
  return
 }
 err = post.fetch(id) ❷
 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
}

❶ 接受Text 接口作为参数

❷ 获取数据并将其存储到Post 结构

修改后的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{}))  ❶

 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")
 }
}

❶ 传入一个FakePost 结构来代替Post 结构

测试用例现在不再向handleRequest 传递Post 结构,而是传递一个FakePost 结构,这个结构就是handleRequest 所需的一切。除此之外,这个测试用例跟之前的测试用例没有什么不同。

为了验证测试替身是否能正常工作,我们可以在关闭数据库之后再次运行测试用例。在这种情况下,旧的测试用例将会因为无法连接数据库而失败,而使用了测试替身的测试用例则因为不需要实际的数据库而一切如常进行。这也意味着我们在辛苦了这么久之后,终于可以独立地测试handleGet 函数了。

跟之前的测试一样,如果handleGet 函数运作正常,那么测试就会通过;否则,测试就会失败。需要注意的是,这个测试用例并没有实际测试Post 结构的fetch 方法,这是因为实施这种测试需要对posts 表执行预设操作和拆卸操作,而重复执行这种操作会在测试时耗费大量的时间。这样做的另一个好处是隔离了Web服务的各个部分,使程序员可以独立测试每个部分,并在发现问题时更准确地定位出错的部分。因为代码总是在不断地演进和变化当中,所以能够做到这一点是非常重要的。在代码不断衍化的过程中,我们必须保证后续添加的部分不会对前面已有的部分造成破坏。

testing 包是一种简单且高效的测试Go程序的方法,它甚至还被用于验证Go自身的标准库,但是为了满足一些领域渴望拥有更多功能的要求,市面上也出现了不少对testing 包进行增强的Go测试库。本节将对Gocheck和Ginkgo这两个流行的Go测试库进行介绍。Gocheck是两者中较为简单的一个,它整合并扩展了testing 包;Ginkgo能够让用户在Go中实现行为驱动开发,但这个库比较复杂,而且学习曲线也比较陡峭。

Gocheck项目提供了check 包,这个包是基于testing 包构建的一个测试框架,并且提供了一系列特性来填补标准testing 包在特性方面的空白,这一系列特性包括:

下载并安装check 包的工作非常简单,可以通过执行以下命令来完成:

go get gopkg.in/check.v1

代码清单8-20展示了使用check 包测试简单Web服务的方法。

代码清单8-20 使用check 包的server_test.go

package main

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "testing"
 . "gopkg.in/check.v1" ❶
)

type PostTestSuite struct {}  ❷

func init() {
 Suite(&PostTestSuite{})  ❸
}

func Test(t *testing.T) { TestingT(t) }  ❹

func (s *PostTestSuite) TestHandleGet(c *C) {
 mux := http.NewServeMux()
 mux.HandleFunc("/post/", handleRequest(&FakePost{}))
 writer := httptest.NewRecorder()
 request, _ := http.NewRequest("GET", "/post/1", nil)
 mux.ServeHTTP(writer, request)

 c.Check(writer.Code, Equals, 200) ❺
 var post Post ❺
 json.Unmarshal(writer.Body.Bytes(), &post) ❺
 c.Check(post.Id, Equals, 1) ❺
}

❶ 导入check 包中的标识符,使程序可以以不带前缀的方式访问它们

❷ 创建测试套件

❸ 注册测试套件

❹ 集成testing 包

❺ 检查语句的执行结果

这个测试程序做的第一件事就是导入包。需要特别注意的是,因为程序是以点(. )方式导入check 包的,所以包中所有被导出的标识符在测试程序里面都可以以不带前缀的方式访问。

之后,程序创建了一个测试套件。测试套件将以结构的形式表示,这个结构既可以像这个例子中展示的一样——只是一个空结构,也可以在结构中包含其他字段,这一点在后面将会有更详细的讨论。除了创建测试套件结构之外,程序还需要把这个结构传递给Suite 函数,以便对测试套件进行注册。测试套件中所有遵循TestXxx 格式的方法都会被看作是一个测试用例,跟之前一样,这些测试用例也会在用户运行测试时被执行。

准备工作的最后一步是集成testing 包,这一点可以通过创建一个普通的testing 包测试用例来完成:程序需要创建一个格式为TestXxx 的函数,它接受一个指向testing.T 的指针作为输入,然后把这个指针作为参数在函数体内调用TestingT 函数。

上述集成操作会导致所有用Suite 函数注册了的测试套件被运行,而运行的结果则会被回传至testing 包。在一切预设操作都准备妥当之后,程序接下来就可以定义自己的测试用例了。在上面展示的测试套件当中,有一个名为TestHandleGet 的方法,它接受一个指向C 类型的指针作为参数,这种类型拥有一些非常有趣的方法,但是由于篇幅的关系,本节无法详细介绍C 类型拥有的所有方法,目前来说,我们只需要知道它的Check 方法和Assert 方法能够验证结果的值就可以了。

例如,在代码清单8-20中,测试用例会使用Check 方法检查被返回的HTTP代码是否为200,如果结果不是200,那么这个测试用例将被标记为“已失败”,但测试用例会继续执行直到结束;反之,如果程序使用Assert 来代替Check ,那么测试用例在失败之后将立即返回。

使用Gocheck实现的测试程序同样使用go test 命令执行,但是用户可以使用check 包专有的特别详细(extra verbose)标志-check.vv 显示更多细节:

go test -check.vv

下面是这条命令的执行结果:

START: server_test.go:19: PostTestSuite.TestGetPost
PASS: server_test.go:19: PostTestSuite.TestGetPost 0.000s
OK: 1 passed
PASS
ok  gocheck  0.007s

正如结果所示,带有特别详细标志的命令给我们提供了更多信息,其中包括测试的启动信息。虽然这些信息对于目前这个例子没有太大帮助,但是在之后的例子中,我们将会看到这些信息的重要之处。

为了观察测试程序在出错时的反应,我们可以小小地修改一下handleGet 函数,把以下这个会抛出HTTP 404状态码的语句添加到函数的return 语句之前:

http.NotFound(w, r)

现在,再执行go test 命令,我们将看到以下结果:

START: server_test.go:19: PostTestSuite.TestGetPost
server_test.go:29:
  c.Check(post.Id, Equals, 1)
... obtained int = 0
... expected int = 1

FAIL: server_test.go:19: PostTestSuite.TestGetPost

OOPS: 0 passed, 1 FAILED
--- FAIL: Test (0.00s)
FAIL
exit status 1

FAIL gocheck  0.007s

正如结果所示,带有特别详细标志的go test 命令在测试出错时将给我们提供非常多有价值的信息。

测试夹具 (test fixture)是check 包提供的另外一个非常有用的特性,用户可以通过这些夹具在测试开始之前设置好固定的状态,然后再在测试中对预期的状态进行检查。

check 包为整个测试套件以及每个测试用例分别提供了一系列预设函数和拆卸函数。比如,在套件开始运行之前运行一次的SetUpSuite 函数,在所有测试都运行完毕之后运行一次的TearDownSuite 函数,在运行每个测试用例之前都会运行一次的SetUpTest 函数,以及在运行每个测试用例之后都会运行一次的TearDownTest 函数。

为了演示这些测试夹具的使用方法,我们需要复用之前展示过的测试程序,并为PUT 方法添加一个测试用例。如果我们仔细地观察已有的测试用例和新添加的测试用例就会发现,在每个测试用例里面,都出现了以下重复代码:

mux := http.NewServeMux()
mux.HandleFunc("/post/", handlePost(&FakePost{}))
writer := httptest.NewRecorder()

这个测试程序的每个测试用例都会创建一个多路复用器,并调用多路复用器的HandleFunc 方法,把一个URL和一个处理器绑定起来。在此之后,测试用例还需要创建一个ResponseRecorder 来记录请求的响应。因为测试套件中的每个测试用例都需要执行这两个步骤,所以我们可以把这两个步骤用作各个测试用例的夹具。

代码清单8-21展示了使用夹具之后的server_test.go

代码清单8-21 使用测试夹具实现的测试程序

package main

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "testing"
 "strings"
 . "gopkg.in/check.v1"
)
type PostTestSuite struct {  ❶
 mux *http.ServeMux
 post *FakePost
  writer *httptest.ResponseRecorder
}

func init() {
 Suite(&PostTestSuite{})
}

func Test(t *testing.T) { TestingT(t) }

func (s *PostTestSuite) SetUpTest(c *C) { ❷
 s.post = &FakePost{}
 s.mux = http.NewServeMux()
 s.mux.HandleFunc("/post/", handleRequest(s.post))
 s.writer = httptest.NewRecorder()
}

func (s *PostTestSuite) TestGetPost(c *C) {
 request, _ := http.NewRequest("GET", "/post/1", nil)
 s.mux.ServeHTTP(s.writer, request)

 c.Check(s.writer.Code, Equals, 200)
 var post Post
 json.Unmarshal(s.writer.Body.Bytes(), &post)
 c.Check(post.Id, Equals, 1)
}

func (s *PostTestSuite) TestPutPost(c *C) {
 json := strings.NewReader(`{"content":"Updated post","author":"Sau
 Sheong"}`)
 request, _ := http.NewRequest("PUT", "/post/1", json)
 s.mux.ServeHTTP(s.writer, request)

 c.Check(s.writer.Code, Equals, 200)
 c.Check(s.post.Id, Equals, 1)
 c.Check(s.post.Content, Equals, "Updated post")
}

❶ 存储在测试套件中的测试夹具数据

❷ 创建测试夹具

为了使用测试夹具,程序必须将它的数据存储在某个地方,并让这些数据在测试过程中一直存在。为此,程序需要给测试套件结构PostTestSuite 添加一些字段,并把想要存储的测试夹具数据记录到这些字段里面。因为测试套件中的每个测试用例实际上都是PostTestSuite 结构的一个方法,所以这些测试用例将能够非常方便地访问到结构中存储的夹具数据。在存储好夹具数据之后,程序会使用SetUpTest 函数为每个测试用例设置夹具。

在创建夹具的过程中,程序使用了存储在PostTestSuite 结构中的字段。在设置好夹具之后,我们就可以对测试程序做相应的修改了:需要修改的地方并不多,最主要的工作是移除测试用例中重复出现的语句,并将测试用例中使用的结构修改为测试夹具中设置的结构。在完成修改之后再次执行go test 命令,我们将得到以下结果:

START: server_test.go:31: PostTestSuite.TestGetPost
START: server_test.go:24: PostTestSuite.SetUpTest
PASS: server_test.go:24: PostTestSuite.SetUpTest 0.000s

PASS: server_test.go:31: PostTestSuite.TestGetPost 0.000s

START: server_test.go:41: PostTestSuite.TestPutPost
START: server_test.go:24: PostTestSuite.SetUpTest
PASS: server_test.go:24: PostTestSuite.SetUpTest 0.000s

PASS: server_test.go:41: PostTestSuite.TestPutPost 0.000s

OK: 2 passed
PASS
ok  gocheck  0.007s

特别详细标志让我们清晰地看到了整个测试套件的运行过程。为了进一步观察整个测试套件的运行顺序,我们可以把以下测试夹具函数添加到测试程序里面:

func (s *PostTestSuite) TearDownTest(c *C) {
 c.Log("Finished test - ", c.TestName())
}
func (s *PostTestSuite) SetUpSuite(c *C) {
 c.Log("Starting Post Test Suite")
}
func (s *PostTestSuite) TearDownSuite(c *C) {
 c.Log("Finishing Post Test Suite")
}

再次运行测试将得到以下结果:

START: server_test.go:35: PostTestSuite.SetUpSuite
Starting Post Test Suite
PASS: server_test.go:35: PostTestSuite.SetUpSuite 0.000s

START: server_test.go:44: PostTestSuite.TestGetPost
START: server_test.go:24: PostTestSuite.SetUpTest
PASS: server_test.go:24: PostTestSuite.SetUpTest 0.000s

START: server_test.go:31: PostTestSuite.TearDownTest
Finished test - PostTestSuite.TestGetPost
PASS: server_test.go:31: PostTestSuite.TearDownTest 0.000s

PASS: server_test.go:44: PostTestSuite.TestGetPost 0.000s

START: server_test.go:54: PostTestSuite.TestPutPost
START: server_test.go:24: PostTestSuite.SetUpTest
PASS: server_test.go:24: PostTestSuite.SetUpTest 0.000s

START: server_test.go:31: PostTestSuite.TearDownTest
Finished test - PostTestSuite.TestPutPost
PASS: server_test.go:31: PostTestSuite.TearDownTest 0.000s

PASS: server_test.go:54: PostTestSuite.TestPutPost 0.000s

START: server_test.go:39: PostTestSuite.TearDownSuite
Finishing Post Test Suite
PASS: server_test.go:39: PostTestSuite.TearDownSuite 0.000s

OK: 2 passed
PASS
ok  gocheck  0.007s

根据测试结果显示,SetUpSuiteTearDownSuite 就如我们之前介绍的一样,只会在测试开始之前和测试结束之后各运行一次,而SetUpTestTearDownTest 则会作为每个测试用例的第一行语句和最后一行语句,在测试用例的开头和结尾分别运行一次。

作为testing 包的增强版本,简单而强大的Gocheck为我们的测试“军火库”加上了一件强有力的武器,如果你想要获得比Gocheck更强大的功能,可以试一试下一节介绍的Ginkgo测试框架。

Ginkgo是一个行为驱动开发 (behavior-driven development,BDD)风格的Go测试框架。BDD是一个非常庞大的主题,想要在小小的一节篇幅里对它进行完整的介绍是不可能的。一言以蔽之,BDD是测试驱动开发 (test-driven development,TDD)的一种延伸,但BDD跟TDD的不同之处在于,BDD是一种软件开发方法而不是一种软件测试方法。在BDD中,软件由它的目标行为进行定义,这些目标行为通常是一系列业务需求。BDD的需求是从行为的角度,通过终端用户的语言以及视角来定义的,这些需求在BDD中称为用户故事 (user story)。下面是通过用户故事对简单Web服务进行描述的一个例子。


故事:

获取一张帖子
为了

向用户显示指定的一张帖子
作为

一个被调用的程序
我需要

获取用户指定的帖子

情景1:

使用一个ID
给定

一个值为`

1`的帖子ID
只要

我发送一个带有该ID的GET请求
那么

我就会获得与给定ID相对应的一张帖子

情景2:

使用一个非整数ID
给定

一个值为`"hello"

`的帖子ID
只要

我发送一个带有该ID的GET请求
那么

我就会获得一个HTTP 500响应

在定义了用户故事之后,我们就可以把这些用户故事转换为测试用例。BDD中的测试用例跟TDD中的测试用例一样,都是在编写实际的代码之前编写的,这些测试用例的目标在于开发出一个程序,让它能够执行用户故事中描述的行为。坦白地说,上面展示的用户故事带有很明显的虚构成分。在更现实的环境中,BDD用户故事最开始通常都是使用更高层次的语言来撰写,然后根据细节进行数次层级划分之后,再分解为更为具体的用户故事,最终,使用高层次语言撰写的用户故事将会被映射到一系列按层级划分的测试套件。

Ginkgo是一个拥有丰富功能的BDD风格的框架,它提供了将用户故事映射为测试用例的工具,并且这些工具也很好地集成到了Go的testing 包当中。虽然Ginkgo的主要用于在Go中实现BDD,但是本节只会把Ginkgo当作一个Go的测试框架来使用。

为了安装Ginkgo,我们需要在终端中执行以下两条命令:

go get github.com/onsi/ginkgo/ginkgo
go get github.com/onsi/gomega

第一条命令下载Ginkgo并将命令行接口程序ginkgo 安装到$GOPATH/bin 目录中,而第二条命令则会下载Ginkgo默认的匹配器库Gomega(匹配器可以对比两个不同的组件,这些组件可以是结构、映射、字符串等)。

在开始学习如何使用Ginkgo编写测试用例之前,让我们先来看看如何使用Ginkgo去执行已有的测试用例——Ginkgo能够自动地对前面展示过的testing 包测试用例进行语法重写,把它们转换为Ginkgo测试用例。

为了验证这一点,我们将会使用上一节展示过的带有依赖注入特性的测试套件为起点。如果你想要保留原有的测试套件,让它们免受Ginkgo的修改,那么请在执行后续操作之前先对其进行备份。在一切准备就绪之后,在终端里面执行以下命令:

ginkgo convert .

这条命令会在目录中添加一个名为 xxx _suite_test.go的文件,其中 xxx 为目录的名字。这个文件的具体内容如代码清单8-22所示。

代码清单8-22 Ginkgo测试套件文件

package main_test

import (
 . "github.com/onsi/ginkgo"
 . "github.com/onsi/gomega"

 "testing"
)

func TestGinkgo(t *testing.T) {
 RegisterFailHandler(Fail)
 RunSpecs(t, "Ginkgo Suite")
}

除此之外,上述命令还会对server_test.go文件 进行修改,代码清单8-23中以粗体的形式展示了文件中被修改的代码行。

代码清单8-23 修改后的测试文件

package main

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "strings"
 . "github.com/onsi/ginkgo"


)

var _ = Describe("Testing with Ginkgo", func() {


 It("get post", func() {



  mux := http.NewServeMux()
  mux.HandleFunc("/post/", handleRequest(&FakePost{}))
  writer := httptest.NewRecorder()
  request, _ := http.NewRequest("GET", "/post/1", nil)
  mux.ServeHTTP(writer, request)

  if writer.Code != 200 {
   GinkgoT().Errorf("Response code is %v", writer.Code)


  }
  var post Post
  json.Unmarshal(writer.Body.Bytes(), &post)
  if post.Id != 1 {
   GinkgoT().Errorf("Cannot retrieve JSON post")


  }
 })


 It("put post", func() {



  mux := http.NewServeMux()
  post := &FakePost{}
  mux.HandleFunc("/post/", handleRequest(post))

  writer := httptest.NewRecorder()
  json := strings.NewReader(`{"content":"Updated post","author":"Sau
 Sheong"}`)
  request, _ := http.NewRequest("PUT", "/post/1", json)
  mux.ServeHTTP(writer, request)

  if writer.Code != 200 {
   GinkgoT().Error("Response code is %v", writer.Code)


  }

  if post.Content != "Updated post" {
   GinkgoT().Error("Content is not correct", post.Content)


  }
 })


})



注意,修改后的测试程序并没有使用Gomega,只是把检查执行结果的语句改成了Ginkgo提供的Errorf 函数和Erro r函数,不过这两个函数跟testing 包以及check 包中的同名函数具有相似的作用。当我们使用以下命令运行这个测试程序时:

ginkgo -v

Ginkgo将打印出一段格式非常漂亮的输出:

Running Suite: Ginkgo Suite
===========================
Random Seed: 1431743149
Will run 2 of 2 specs
Testing with Ginkgo
 get post
 server_test.go:29
•
------------------------------
Testing with Ginkgo
 put post
 server_test.go:48
•
Ran 2 of 2 Specs in 0.000 seconds
SUCCESS! -- 2 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 577.104764ms
Test Suite Passed

自动转换已有的测试,然后漂亮地打印出它们的执行结果,这给人的感觉真的非常不错!但如果我们根本没有现成的测试用例,是否需要先创建出testing 包的测试用例,然后再把它们转换为Ginkgo测试呢?答案是否定的!没有必要多此一举,让我们来看看如何从零开始创建Ginkgo测试用例吧。

Ginkgo提供了一些实用工具,它们能够帮助用户快速、方便地创建测试。首先,清空与上一次测试有关的全部测试文件,包括之前Ginkgo创建的测试套件文件,然后在程序的目录中执行以下两条命令:

ginkgo bootstrap
ginkgo generate

第一条命令会创建新的Ginkgo测试套件文件,而第二条命令则会为测试用例文件生成代码清单8-24所示的骨架。

代码清单8-24 Ginkgo测试文件

package main_test

import (
 . "<path/to/your/go_files>/ginkgo"
 . "github.com/onsi/ginkgo"
 . "github.com/onsi/gomega"
)

var _ = Describe("Ginkgo", func() {

})

注意,因为Ginkgo会把测试用例从main 包中隔离开,所以新创建的测试文件将不再属于main 包。此外,测试程序还通过点导入(dot import)语法,将几个库中包含的标识符全部导入到顶层命名空间。这种导入方式并不是必需的,Ginkgo在它的文档里面提供了一些关于如何避免这种导入的说明,但是在不使用点导入语法的情况下,用户必须导出main 包中需要使用Ginkgo测试的所有函数。例如,因为我们接下来就要对简单Web服务的HandleRequest 函数进行测试,所以这个函数一定要被导出,也就是说,这个函数的名字的首字母必须大写。

另外需要注意的是,Ginkgo在调用Describe 函数时使用了var _ = 这一技巧。这种常用的技巧能够在调用Describe 函数的同时,避免引入init 函数。

代码清单8-25展示了使用Ginkgo实现的测试用例代码,这些代码是由早前撰写的用户故事映射而来的。

代码清单8-25 使用Gomega匹配器实现的Ginkgo测试用例

package main_test

import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 . "github.com/onsi/ginkgo"
 . "github.com/onsi/gomega"
 . "gwp/Chapter_8_Testing_Web_Applications/test_ginkgo"
)

var _ = Describe("Get a post", func() { ❶
 var mux *http.ServeMux
 var post *FakePost
 var writer *httptest.ResponseRecorder

 BeforeEach(func() {
  post = &FakePost{}
  mux = http.NewServeMux()
  mux.HandleFunc("/post/", HandleRequest(post))
  writer = httptest.NewRecorder()
 })

 Context("Get a post using an id", func() { ❷❸
  It("should get a post", func() {
   request, _ := http.NewRequest("GET", "/post/1", nil)
   mux.ServeHTTP(writer, request)

   Expect(writer.Code).To(Equal(200)) ❹

   var post Post
   json.Unmarshal(writer.Body.Bytes(), &post)

   Expect(post.Id).To(Equal(1))
  })
 })

 Context("Get an error if post id is not an integer", func() { ❺
  It("should get a HTTP 500 response", func() {
   request, _ := http.NewRequest("GET", "/post/hello", nil)
   mux.ServeHTTP(writer, request)

   Expect(writer.Code).To(Equal(500))
  })
 })
})

❶ 用户故事

❷ 使用Gomega匹配器

❸ 情景1

❹ 使用Gomega 对正确性进行断言

❺ 情景2

注意,这个测试程序使用了来自Gomega包的匹配器:Gomega是由Ginkgo开发者开发的一个断言包,包中的匹配器都是测试断言。跟使用check 包时一样,测试程序在调用Context 函数模拟指定的情景之前,会先设置好相应的测试夹具:

var mux *http.ServeMux
var post *FakePost
var writer *httptest.ResponseRecorder

BeforeEach(func() {
 post = &FakePost{}
 mux = http.NewServeMux()
 mux.HandleFunc("/post/", HandleRequest(post)) ❶
 writer = httptest.NewRecorder()
})

❶ 对 main 包中导出的函数进行测试

注意,为了从main 包中导出被测试的处理器,我们将处理器的名字从原来的handleRequest 修改成了首字母大写的HandleRequest 。除使用的是Gomega的断言之外,程序中展现的测试场景跟我们之前使用其他包进行测试时的场景非常类似。下面是一个使用Gomega创建的断言:

Expect(post.Id).To(Equal(1))

在这个断言中,post.Id 是要测试的对象,Equal 函数是匹配器,而1 是预期的结果。针对我们写的测试情景,执行ginkgo 命令将返回以下结果:

Running Suite: Post CRUD Suite
==============================
Random Seed: 1431753578
Will run 2 of 2 specs

Get a post using an id
 should get a post
 test_ginkgo_test.go:35
•
------------------------------
Get a post using a non-integer id
 should get aHTTP500 response
 test_ginkgo_test.go:44
•
Ran 2 of 2 Specs in 0.000 seconds
SUCCESS! -- 2 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 648.619232ms
Test Suite Passed

好的,关于使用Go对程序进行测试的介绍到这里就结束了,在接下来的一章中,我们将会讨论如何在Web应用中使用Go的一个关键长处——并发。