在Go语言中如何进行单元测试

发表于:2017-10-09来源:徐新华作者:徐新华点击数: 标签:Go语言
在写《Go语言标准库》的第九章 —— 测试 时,看到了此文,讲解挺细致,于是翻译为中文,作为学习《Go语言标准库》的第九章的补充材料。如果你花过一些时间学习如何编程,你很

在写《Go语言标准库》的第九章 —— 测试 时,看到了此文,讲解挺细致,于是翻译为中文,作为学习《Go语言标准库》的第九章的补充材料。

如果你花过一些时间学习如何编程,你很可能见过许多地方提过测试。似乎每个人都在谈论测试,似乎都同意你应该进行测试,但这到底需要什么呢?

在这篇文章中,我将尝试回答这个问题,首先解释什么是测试,然后我会用 Go 去深入实际编写测试。在编写测试时,我将通过编写自定义 main 包,使用 testing 包以及更复杂的功能(如自定义 setup 和 teardown)以及创建可以用作测试用例的示例代码来覆盖所有内容。

什么是测试?

让我们从最简单的问题开始 — 什么是测试?

简单地说,测试是一个可重复的过程,它验证某个东西是否按预期工作。虽然您通常会听到在软件世界中的测试,但它们并不限于软件。

如果您购买和转售二手电视机,您可能会有一个测试过程,包括将电视插入笔记本电脑的 HDMI 端口,并验证显示器和音频是否在电视上工作。这也是测试。

虽然测试似乎需要一些复杂和自动化的过程,但事实是测试可以从手动键入 www.yoursite.com 到您的浏览器,以验证您的部署是否有效,或者它们可能与 Google’s DiRT 一样复杂 — 该公司试图测试他们的系统如何在僵尸启示的情况下自动响应。测试只是一种帮助确定某件事情在特定情况下是否按预期工作的方法。

在前面的电视示例中,您的测试用于确保在插入标准输入时电视工作正常;而在软件世界中, 您的测试可能用于确定某个函数是否按您的预期运行。

编写一个程序测试

虽然测试不需要,但在编程世界中,测试通常通过编写更多代码来自动化。它们的目的与任何手动执行的测试相同,但由于它们是用代码编写的,所以这些测试具有更多的优势,可以更快地执行,并且您可以与其他开发人员共享它们。

例如,假设我们需要编写一个函数 Sum 来计算 slice 中提供的所有整数的和,并返回它,我们想出了下面的代码。

func Sum(numbers []int) int {  
  sum := 0
  // 这个 bug 是故意的
  for n := range numbers {
    sum += n
  }
  return sum
}

现在,假设您想为这个函数编写一些测试,以确保它按您的预期运行。如果您对测试工具不熟悉 (如果您正在阅读这篇文章,我假设您确实不熟悉),一个方法是创建一个使用 Sum() 函数的 main 包,如果结果不是我们所期望的,则显示一条错误消息。

package main

import (  
  "fmt"

  "calhoun.io/testing101"
)

func main() {  
  testSum([]int{2, 2, 2, 4}, 10)
  testSum([]int{-1, -2, -3, -4, 5}, -5)
}

func testSum(numbers []int, expected int) {  
  sum := testing101.Sum(numbers)
  if sum != expected {
    message := fmt.Sprintf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, sum)
    panic(message)
  }
}

注意:这里假定您的 Sum() 函数在一个名为 testing101 的包中。如果您在本地自己编写代码,记得导入正确的包。

如果我们运行此代码,会注意到 Sum() 函数实际上没有按预期的方式工作,我们没有得到期望的值:10,而是得到 6。进一步排查后,我们可能会意识到,我们使用的是在切片中的索引,而不是切片中每个项的实际值。为了解决这个问题,我们需要更新该行:

for n := range numbers {

改为

for _, n := range numbers {

在进行更改后, 我们可以重新运行 main() 函数,这次不会得到任何输出,说明测试用例失败。这就是测试的威力 — 在几分钟内,我们就我们的代码是否正常工作提供了反馈意见。我们可以快速验证代码是否如我们修改的方式工作。另外,如果我们将此代码发送给其他开发人员,他们还可以继续运行相同的测试,并验证它们没有破坏您的代码。

通过 go test 进行测试

虽然上面所示的方法可能适用于小型项目,但要编写一个 main 包来测试所有我们想要检测的内容会变得非常麻烦。幸运的是,在 testing 包,Go 为我们提供一些很好的功能,我们可以在不需要太多学习的情况下使用它们。

若要在 Go 中开始使用测试,首先需要定义要测试的包。如果还没有,请创建一个名为 testing101 的包,并创建文件 sum.go,添加上下面的代码:(代码跟上面的一样)

package testing101

func Sum(numbers []int) int {  
  sum := 0
  // 这个 bug 是故意的
  for _, n := range numbers {
    sum += n
  }
  return sum
}

接下来在同一个包中,创建一个名为 sum_test.go 的文件,并将下面的代码添加到其中。

package testing101

import (  
  "fmt"
  "testing"
)

func TestSum(t *testing.T) {  
  numbers := []int{1, 2, 3, 4, 5}
  expected := 15
  actual := Sum(numbers)

  if actual != expected {
    t.Errorf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, actual)
  }
}

现在我们要运行我们的测试,所以在终端中切换到 testing101 包所在目录,并使用下面的命令运行测试。

go test -v

你应该看到像这样的输出:

=== RUN TestSum

— PASS: TestSum (0.00s)

PASS

ok calhoun.io/testing101 0.005s

恭喜!您刚刚使用 Go 内置的 testing 编写了第一个测试。现在,让我们深入了解实际发生的事情。

首先,是我们的文件名。Go 要求所有的测试都在以 _test.go 结尾的文件中。这使得我们在检查另一个 package 包的源代码时,确定哪些文件是测试和哪些文件实现功能非常容易。

在看了文件名之后,我们可以直接跳转到代码中,将测试包导入。它为我们提供了一些类型 (如testing.T) ,这些类型提供常见功能,比如在测试失败时设置错误消息。

接下来,是函数 TestSum()。所有的测试都应该以 func TestXxx(*testing.T) 的格式来编写。其中 Xxx 可以是任何字符或数字,而第一个字符需要是大写字符或数字。(译注:一般,Xxx 就是被测试的函数名)

最后,如上所述,我们使用了 TestSum 函数中的参数 *tesing.T 。如果我们没有得到预期的结果,我们使用它来设置一个错误,当我们运行测试时,该错误将显示在终端上。若要查看此操作,请将测试代码中的 expected 更新为 18,而不更新 numbers 变量,然后使用 go test -v 运行测试。您应该会看到显示如下所示错误信息的输出:

=== RUN TestSum

— FAIL: TestSum (0.00s)

sum_test.go:14: Expected the sum of [1 2 3 4 5] to be 18 but instead got 15!

FAIL

exit status 1

FAIL calhoun.io/testing101 0.005s

在本节的所有内容中,您应该能够开始对所有代码进行一些基本测试,但如果需要为同一函数添加更多测试用例,或者需要构造自己的类型来测试代码,会发生什么情况?

每个函数多个测试用例

在上面的 case 中,我们的 Sum() 函数的代码非常简单,但是当您编写自己的代码时,您可能会发现自己想要为每个函数添加更多的测试用例而不仅仅是一个。例如,我们可能希望验证 Sum() 是否能正确处理负数。

在 Go 中运行多个测试用例有几种选择。一个选择是简单地在我们的 sum_test.go 中创建另一个函数。例如,我们可以添加函数 TestSumWithNegatives() 。这是迄今为止最简单的方法,但它可能导致某些代码重复,而且我们的测试输出中没有很好的嵌套测试用例。

另一种选择,不是创建多个 TestXxx() 函数,而是使用 testing.T 的 Run 方法,它允许我们传递一个要运行的子测试的名称,以及一个用于测试的函数。打开 sum_test.go 并更新为如下代码:(参考: 【译】子测试和子基准测试的使用 )

package testing101

import (  
  "fmt"
  "testing"
)

func TestSum(t *testing.T) {  
  t.Run("[1,2,3,4,5]", testSumFunc([]int{1, 2, 3, 4, 5}, 15))
  t.Run("[1,2,3,4,-5]", testSumFunc([]int{1, 2, 3, 4, -5}, 5))
}

func testSumFunc(numbers []int, expected int) func(*testing.T) {  
  return func(t *testing.T) {
    actual := Sum(numbers)
    if actual != expected {
      t.Error(fmt.Sprintf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, actual))
    }
  }
}

此示例使用了一个闭包,它是一个函数,它使用了没有直接传递给它的变量(外部函数的变量)。这对于创建一个只接受 testing.T 变量的函数很有用,而且也可以访问我们要为每个测试用例动态定义的变量。如果你不知道什么是闭包,我建议你看看 stack overflow 上的这个问题 ,如果这还帮助不了你,你可以发送电子邮件给我(jon@calhoun.io),我会尝试写一篇关于闭包的文章。

通过使用闭包,我们可以在测试中动态地设置变量,而不需要一次又一次地编写相同的代码。现在,如果我们使用go test -v 运行我们的测试,我们将得到以下输出:

=== RUN TestSum

=== RUN TestSum/[1,2,3,4,5]

=== RUN TestSum/[1,2,3,4,-5]

— PASS: TestSum (0.00s)

— PASS: TestSum/[1,2,3,4,5] (0.00s)

— PASS: TestSum/[1,2,3,4,-5] (0.00s)

PASS

ok calhoun.io/testing101 0.005s

这些测试现在用它们的输入做了标记,并且嵌套在 TestSum 测试用例下,使得调试任何问题都非常容易做到。

译注:还有一种选择其实更常用,那就是表格测试。

示例作为测试

几乎所有开发人员的目标之一就是编写易于使用和维护的代码。为了实现这一点,包含如何使用代码的示例通常会有所帮助。Go 的 testing 包提供了帮助定义示例源代码的功能。作为附加的用途,testing 包还可以测试您的示例,以确保它们在测试过程中输出您期望的内容。

打开 sum_test.go,在文件末尾增加如下代码:

func ExampleSum() {  
  numbers := []int{5, 5, 5}
  fmt.Println(Sum(numbers))
  // Output:
  // 15
}

然后使用 go test -v 运行测试。您现在应该在结果中看到此示例函数,但它是如何被测试的呢?

Go 使用在 ExampleXxx() 函数底部的 “Output 注释” 部分来确定预期的输出是什么,然后在运行测试时,它将实际输出与注释中的预期输出进行比较,如果不匹配,将触发失败的测试。这样,我们可以同时编写测试和示例代码。

除了创建用于测试的示例外,示例还用于显示在生成的文档中。例如,上面的例子可以用来为我们的 testing101 包生成文档,类似下面的截图。

更复杂的例子

在测试足够的代码和编写足够的示例之后,您最终会发现某些测试在单个函数中不容易编写。发生这种情况的一个常见原因是,您需要在多次测试之前或之后设置(setup)或拆卸(teardown)东西。例如,您可能希望从环境变量获取数据库 URL,并在运行多个测试之前设置到数据库的连接,而不是单独为每个测试重新连接到数据库。

为支持该功能,Go 提供了 TestMain(*testing.M) 的函数,它在需要的时候代替运行所有的测试。使用 TestMain() 函数时,您有机会在测试运行之前或之后插入所需的任何自定义代码,但唯一需要注意的是必须处理 flag 解析并使用测试结果调用 os.Exit()。这听起来可能很复杂,但实际上只有两行代码。

flag.Parse()  
os.Exit(m.Run())

让我们看一个更完整的例子。在我们的 testing101 包中,创建一个名为 db_test.go 的文件,并将下面的代码添加到其中。

package testing101

import (  
  "flag"
  "fmt"
  "os"
  "testing"
)

var db struct {  
  Url string
}

func TestMain(m *testing.M) {  
  // Pretend to open our DB connection
  db.Url = os.Getenv("DATABASE_URL")
  if db.Url == "" {
    db.Url = "localhost:5432"
  }

  flag.Parse()
  exitCode := m.Run()

  // Pretend to close our DB connection
  db.Url = ""

  // Exit
  os.Exit(exitCode)
}

func TestDatabase(t *testing.T) {  
  // Pretend to use the db
  fmt.Println(db.Url)
}

在这段代码中,我们首先创建一个名为 db 的全局变量,它是一个包含 Url 的结构体。通常,这将是一个实际的数据库连接,但对于这个例子,我们只是示例,只设置了 Url。

接下来在 TestMain() 中,我们假装通过分析环境变量 DATABASE_URL 并将其设置为 db.Url 属性来打开数据库连接。如果这是一个空字符串,则默认为 localhost:5432,Postgres 使用的默认端口。

之后我们解析标志 (这样我们的 go test -v 中的 -v 选项可以工作),调用 m.Run() 并将结果状态码存储在 exitCode 中,以便在结束测试时可以引用它。如果你对退出状态代码不太了解,现在就不重要了。只需记住,我们需要存储从 m.Run() 返回的状态码,以后再使用它。

在运行测试后,我们假装通过将 db.Url 属性设置为空字符串来关闭数据库连接。

最后,我们使用 os.Exit(exitCode) 退出。这将导致当前程序 (我们正在运行的测试) 使用我们提供的状态代码退出。通常,除零之外的任何内容都将被视为错误。

总结

看完这里的所有内容,您可能准备好了要开始为 Go 编写的代码编写测试。但请记住,这只是表明您可以编写测试并不意味着您应该。过度测试和没有测试几乎一样糟糕,因为它可能导致需要维护的大量测试代码。

原文转自:https://www.calhoun.io/how-to-test-with-go/