前言

Go 使编写测试非常简单。实际上,测试工具是内置在标准工具链里的,你可以简单地运行 go test 来运行你的测试,无需安装任何额外的依赖或任何别的东西。测试包是标准库的一部分,我很高兴地看到它的使用范围非常广泛。

当你在使用 Go 编写服务实现时,希望你的测试覆盖率随着时间的推移而增长。随着测试范围的扩大,测试运行时间也会变长。你希望用服务集成及集成测试来测试服务的重要部分。你发现在某些情况下,集成测试和各种公共服务的耦合对 CI 和开发产生限制。

集成测试

我是集成测试的忠实信徒。有人可能无法直接看到它的好处,但对于 LTS(长期支持)的版本,进行集成测试是一个很好的主意,因为你显然想要随着时间的推移升级你的服务。如果你要从 MySQL 5.7 切换到 8.0 (甚至是换成 PostgreSQL),你需要合理地确保你的服务依然正常工作,然后你可以检测问题并根据需要对实现进行更新。

集成测试最近对我有用的一个例子是检测到 MySQL 保留字增加的情况:我有一个数据库部署里用到了 rank 字段。这个词在 MySQL 5.7 及之前是可以使用的,但在 MySQL 8.0 里它变成了一个保留字。集成测试捕获了这个问题,而模拟(mock)则无法做到这一点。

模拟是单元测试的一种扩展,而由于集成测试可能意味着高昂的成本,今天做集成测试比过去容易得多。随着 Docker 的不断发展,并有了像 Drone CI 这样 Docker-first 的 CI,我们可以在 CI 测试套件里声明我们的服务。让我们看一下我定义的 MySQL 服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:

- name: crust-db
  image: percona:8.0
  ports:
  - 3306
  environment:
    MYSQL_ROOT_PASSWORD: bRxJ37sJ6Qu4
    MYSQL_DATABASE: crust
    MYSQL_USER: crust
    MYSQL_PASSWORD: crust

这基本上就是随我们的测试和构建一起开启数据库所需的全部。虽然在过去,这可能意味着你需要一个一直在线的数据库实例,你需要在某处进行管理,而今天大门已经打开,基本上你可以在你所用的 CI 框架里声明服务的一切所需。

“Go 以及集成测试: 使用 Drone CI #golang” via @TitPetric

我有点跑题了,但这里的学问是 - 如果你可以避免模拟一些东西,尤其是在你掌控下的服务,一定要考虑编写集成测试。你无需借助使用 go-get 获取的像 gomock 或 moq 这样的项目。模拟一切是不明智的(例如,net.Conn 不需要模拟,它足够简单,可以在你的测试中创建你自己的 client/server,它将存在于内存中)。

实际上,在集成测试和模拟之间也有中间立场,你可以编写像 Redis 这样的简单外部服务的 fake 实现,但你仍然不能捕捉到真实服务的所有细微之处。基本上,只满足你用到的简单接口大大降低了实现面(implementation surface),这就只需实现你用到的 API 子集的行为。

测试范围(testing surface area)

我正在开发一个项目,目前有 53 个测试文件,其中 28 个是需要外部服务(例如上述的数据库)的集成测试。你可能并不总是处理完整的环境,或者可能只对在项目中分散的一小部分测试感兴趣,并且你希望能够运行这些(且只运行这些)。

查看 testing 包的 API 面(API surface),我们注意到有一个 Short() 函数可用,它在运行 Go test 时对 -test.short 起作用。这使得我们在想运行测试的某个子集时可以跳过一些测试:

1
2
3
4
5
6
func TestTimeConsuming(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode.")
    }
    ...
}

从纸面上看,这意味着你在以 short 模式运行时可以跳过集成测试。但即使从上面的例子也可以看出,当测试持续时间是个重要因素时可以用来跳过某些测试,这才是动机 —— 实际上,这个应该仅适用于基准测试。

那么,当考虑到你需要显式地以 -bench 参数启用基准测试时,你可能会琢磨一个基准与另一个基准测试能否比较快慢。Go 已经很聪明,它默认限制了每个基准运行的时间,而是否要修改这个配置,以及是否想同时使用 short 模式和基准,都由你来决定 - 对我来说,两个选项同时使用毫无意义。

事实上,short 测试标记不应该用来跳过集成测试。它的目的是加速构建过程,但是代码判断或人为地判断哪个测试应该是 short 或是 long 让人望而却步。强调:要么运行所有的基准测试要么不运行。随着测试集的增长,short 测试标记无法给我们所需的灵活性,所以我们需要一种更具声明性的方式来界定我们需要运行哪些类型的测试。

更好的方式

现在,传统观点会说“运行所有的测试”。作为真正了解人们如何处理问题,提出问题以及确立实践准则的工程师中的一个 —— 现在,这可以帮助你找到一个更好的解决办法,解决并不只是你遇到的问题。

在 2016 年,以及 2016 年晚些时候,Peter Bourgon 写了两篇极好的长篇幅文章,这些文章对需要实现实际服务并超出基本实现的人来说,是参考书一样的存在:

  • Go best practices for production environments (2014)
  • Go best practices, six years in (2016)

在 2014 年的文章里,Peter 建议使用构建标记来引入有价值的测试习惯:

包测试主要针对单元测试,但对集成测试来说,事情有点棘手。启动外部服务的过程通常依赖于你的集成环境,不过我们确实找到了一个针对它们进行集成测试的好习惯。写一个 integration_test.go 并给它一个 integration 的构建标记。为服务地址以及连接字符串等定义(全局的)flag,并在测试中使用它们。

事实上,Peter 建议使用 Go 的构建标记来标识集成测试。如果你需要一个单独的环境来运行这些测试,你只要使用 -tags=intergration 作为 Go test 的参数。

这完全合乎情理 —— 尽管我在的这个项目的集成测试需要花费一分钟左右,但我知道在有的项目里需要几个小时。这些项目可能有很特殊的专用测试设置,这样你也可以不测试这些服务的配置 —— 它们只在测试环境中使用。

我很想知道他的观点在 2014 到 2016 年是否发生了什么变化。如果有的话,作者会深入研究各种非标准测试包如何成为他们的 DSL(领域特定语言)。但是经验是一位好老师,他并没有对一个 http.Client 进行测试,并指出你不想测试请求进入的 HTTP transport 或正在写文件的磁盘上的路径。

在单元测试中你应该专注于业务逻辑,并且通过集成测试,您将验证集成服务的功能,而不是标准库或第三方软件包如何实现集成。

“Go 测试: 哪个适合你 - 单元测试还是集成测试? #golang” via @TitPetric

边界情况

将你的应用程序与第三方服务集成是很常见的,由于 API 弃用是可能发生的,所以集成测试可能还需要验证应用程序的响应是否仍然有意义。 因此,Peter 的文章需要一点改进。

你不能总是依赖你正在使用的 API;它会在未来几年都保持原样吗?没有人希望你创建一堆 GitHub 用户和组织来测试你的 webhook 端点和集成,但这并不意味着你不会偶尔需要这样做。

一个最近的例子是 larger deprecation of Bitbucket APIs due to GDPR. 这篇弃用通知是在大约一年前宣布的, 从 10 月开始,并计划在 2019 年 4 月底废弃各种 API 及返回的数据,可能会对现有的各种 CI 集成造成严重破坏。

考虑到这一点,我这样扩展了 Peter 的建议:

1
2
3
4
5
6
// +build unit - 不需要任何服务的测试,
// +build integration - 一个强制标记来测试我们自己的服务,
// +build external - 针对第三方和公共服务进行测试,
// +build integration,external - 针对我们自己的服务以及公共服务进行测试,
// +build !integration,external - 专门针对公共服务进行测试,
// +build unit integration - 不依赖服务,提供集成功能

我们的测试通常属于单元测试、集成测试或外部测试的某一类,或者是它们的某种组合。我们肯定希望在 CI 任务中跳过 external 测试,原因显而易见,但如果我们正在考虑调试开发中的一些相关问题,它们是非常有价值的。我们经常需要定位到具体包中具体的测试,因此运行类似下面的内容是有意义的:

1
go test --tags="integration external" ./messaging/webhooks/...

根据你的构建标记,这可能会运行你的代码库某个子集里面的所有集成和外部测试,跳过单元测试,或者它可能只运行那个既是集成测试也是外部测试的测试。 无论哪种方式,你都专注于包实现,尤其是该包中与提供的标记匹配的所有测试的子集。

“Go 测试: 按需运行集成测试的实用方法 #golang” via @TitPetric

对于 CI 任务,范围确定为:

1
go test --tags="unit integration" ./...

这样,你可以完整地测试所有集成测试,以及完整的包范围。 我们将跳过可能导致我们的 CI 构建失败的 external 和 integration AND external 测试,不让它们成为构建的问题。可能每月有那么一天,GitHub 或 Bitbucket 是坏的,我们只能一直看着它们的状态页面。

因此,基本上,除了将某些测试标记为 integration 之外,我们还希望将其他标记为 unit 和 external ,以便我们可以根据需要在开发环境中跳过它们。 没有人喜欢运行完整的测试集,并且发现它仅仅因为 GitHub 出问题而失败。 而具有开发和调试目的的选项是非常宝贵的。

对测试进行测试

在重构测试时,你经常只会在运行测试时才发现,有些符号或其他东西已经不存在了,导致你的测试无法编译。这个问题的一个好的解决办法是仅仅针对测试文件的编译步骤进行测试。有一些东西可以发挥作用:

  1. 可以通过给 go test 填写 -run 参数来跳过测试。你可以运行 go test -run=^$ ./…,它将有效地编译你的完整测试集并跳过所有测试。这对于运行时间较长的 CI 任务非常有用,因为它实际上是一个编译时的检查,确保所有测试都是可运行的。但是,这仍然会运行你的 TestMain 函数。

  2. Go 1.10 引入了 -failfast 标志。如果你的某些测试失败而你有一个非常大的测试集,那么在错误/失败之间会有很多输出,在其他测试完成之前,以及通知你失败之前也会有很多。使用此选项,你可以对这个问题稍做优化,代价是同一测试集中在之后运行的测试中可能还会有失败的。这是测试所有内容和报告所有错误,或仅在发现第一个错误之前进行测试的区别。

  3. -failfast 标志对 ./ … 没有任何作用,例如,如果其中一个包由于编译错误而失败,它将继续针对剩余的检测到的包进行测试。

这些基本上是围绕 golang/go#15535 的问题,实际上这意味着我们无法像使用 go build 一样只针对测试的编译进行测试。

> Go 测试:编译时检查你的测试而无需运行它们 #protip #golang via @TitPetric

使用测试套件(test suites)

测试套件是本篇文章中最重要的策略。它是一种针对拥有多个实现的通用接口的测试,在下面的例子中,你们将看到我是如何将 Thinger 接口的不同实现传递进同一个测试函数,并且让他们测试通过。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Thinger interface {
    DoThing(input string) (Result, error)
}

// Suite tests all the functionality that Thingers should implement
func Suite(t *testing.T, impl Thinger) {
    res, _ := impl.DoThing("thing")
    if res != expected {
        t.Fail("unexpected result")
    }
}

// TestOne tests the first implementation of Thinger
func TestOne(t *testing.T) {
    one := one.NewOne()
    Suite(t, one)
}

// TestOne tests another implementation of Thinger
func TestTwo(t *testing.T) {
    two := two.NewTwo()
    Suite(t, two)
}

幸运的读者可能已经接触过使用该测试策略的代码库。在基于插件开发的系统经常能看到这种测试方式。针对接口的测试可以被用来验证他的所有实现是是否满足接口所需的行为。

测试套件能够让我们在面对一个接口多个实现的时候不用重复的为特定版本书写测试,这会节省我们很多的时间。并且当你切换接口的底层实现代码的时候,不用再写额外的测试,就能保证程序的稳定性。

这里有一个完整的例子。虽然这个例子都是相同的设计。你可以把它想象成一个是远程数据库(Mysql),一个是内存数据库(sqlite)。

另外一个比较棒的例子就是 golang.org/x/net/nettest 包。 当我们实现了自定义的 net.Conn 接口的时候,可以使用这个包(golang.org/x/net/nettest)来直接验证我们的实现是否满足接口的要求,而不用自己重新设计测试代码。

公有以及私有测试 API

大多数情况下,测试都是写在和 package 相同的包中并以 pkg_test.go 命名。而一个单独的测试包就是将测试代码和正式代码分割在不同的包中。一般单独的测试包都以包名+_test 命名(例如:foo 包的测试包为 foo_test)。在测试包中你可以把需要测试的包和其他测试依赖的包一起导入进去。这种方式能让测试更加的灵活。当遇到包中循环引用的情况,我们推荐这种变通的方式。他能防止你对代码中易变部分进行测试。并且能让开发者站在包的使用者的角度上来使用自己开发的包。如果你开发的包很难被使用,那么他也肯定很难被测试。

这种测试方法通过限制易变的私有变量来避免容易发生改变的测试。如果你的代码不能通过这种测试,那么在使用的过程中肯定也会有问题。

这种测试方法也有助于避免循环的引用。大多数包都会依赖你在测试中所要用到的包,所以很容易发生循环依赖的情况。而这种单独的测试包在原包,和被依赖包的层次之外,就不会出现循环依赖的问题。一个例子就是 net/url 包中实现了一个URL的解析器,这个解析器被 net/http 包所使用。但是当对 net/url 包进行测试的时候,就需要导入 net/http 包,因此 net/url_test 包产生了。

现在当你使用一个单独的测试包的时候,包中的一些结构体或者函数由于包的可见性的原因在单独的测试包中不能被访问到。大部分人在基于时间的测试的时候都会遇到这种问题。针对这种问题,我们可以在包中 xx_test.go 文件中将他们导出,这样我们就可以正常的使用了。这样在测试中,你就是包的使用者,让你更好地检查包的公有 API 是否可用。

Go坚称,同一文件夹中的文件属于同一个软件包,但_test.go文件除外。将测试代码移出软件包可以让您像实际使用软件包一样编写测试。您不能摆弄内部结构,而是专注于公开的接口,并始终在考虑可能会添加到API的任何噪声。

这样您就可以随意更改内部结构,而无需调整测试代码。

如果确实需要对某些内部组件进行单元测试,请创建另一个文件,其后缀为“ _internal_test.go”。内部测试必定比接口测试要脆弱得多,但是内部测试是确保内部组件运行良好的好方法,并且在进行测试驱动的开发时特别有用。

此外,有一些适用于任何代码库的准则:

  • 如果你在做内部测试,给你的文件加 _internal_test.go 后缀,
  • 如果你是在做黑盒测试,你的文件应该只有 _test.go 后缀。

特别地,对于名为 store 的东西,你应该有:

  • store.go —— 主包(package store)
  • store_test.go—— 黑盒测试(package store_test)
  • store_internal_test.go —— 内部测试(package store)

有一些关于如何使用这些准则的例子。在 Michael Hashimoto 的一次题为高级的 Go 测试的演讲里,他主张测试作为公共 API。

  • Hashimoto 公司较新的项目采取了使用 “testing.go” 或 “testing_*.go” 文件的实践。

  • 这些文件本身是包的一部分(与普通的测试文件不同)。这些都是为提供模拟,测试治理,帮助方法等而导出的 API。

  • 允许别的包使用我们的包进行测试,且无需为了在一个有意义的测试中使用我们的包而对所需组件进行彻底改造。

对于此我有一个问题(也许不是一个特别相关的问题),对公共 API 的任何修改都需要有某种兼容性保证。尽管这本身是可以接受的,但它并不是一个明确的规范。在大多数情况下,将这些测试函数限定在当前项目测试的范围内是更容易接受的。

我会只是将这些函数添加在 store_internal_test.go 中 —— 在 *_test.go 中定义的任意公共标识符在包中依然是可用的,不过只能在测试中访问。当你的应用被编译时,不会去拉取你在测试文件中声明的任何东西。当你改变主意,需要将其中一些变为公共的 —— 你只需将相关代码移动到 testing.go 文件中,而不需要修改任意一行测试代码。

“Go 测试:你是否应该用公共 API 提供测试所需设施?#golang” via @TitPetric

以上建议的原则也适用于从包中对外暴露一些私有符号,以便在黑盒测试中使用。我似乎无法找到一个强有力的例子来证明这种方法的合理性,除开上面讲到的循环引用的问题,从你的包中对外暴露内部的东西然后只用于你的测试是可以做到的。但如果沿这条路走下去,你实际上是将内部测试和黑盒测试混在一起,我建议你不要这么做。内部的东西会变化,导致你的测试也变得更脆弱。

在这片荒野之地很少有例子,不过还是有一些:

  • API Testing - Swagger —— 为黑盒测试暴露/包装私有函数提供的功能,
  • Separate _test package —— 通过额外的文件避免导出/模拟(并没有链接示例),
  • Export unexported method for test —— 为测试导出私有函数的一个不好的例子,

实际上,在大多数情况下人们可以编写内部测试来实现相同的目标。 我并不是在提倡,尤其是这样的_internal_test.go 文件应该将内部暴露给黑盒测试,但是我看到了使用它们来提供有一天可能成为公共包 API 的效用实体,是有意义的。 这仍然是太大的一步,但这一切都取决于你的需求。 如果你不希望在给定日期或给定版本之前发布某部分 API,可以采取这种方式,对于每个 API,可以将其实现为公共包 API,而无需真正对外发布供测试之外使用。

使用Go Interface

如果您需要模拟代码所依赖的东西以进行正确的测试,则很可能是接口的理想选择。即使您依赖于无法更改的外部程序包,您的代码仍然可以采用外部类型可以满足的接口。

经过几年的模拟编写,我终于找到了模拟接口的完美方法,并且我制作了一个工具,可以为我们编写代码,而无需向项目添加任何依赖关系:签出Moq。

假设我们要导入此外部软件包:

1
2
3
4
5
6
7
8
9
package mailman
import net/mail
type MailMan struct{}
func (m *MailMan) Send(subject, body string, to ...*mail.Address) {
  // some code
}
func New() *MailMan {
  return &MailMan{}
}

如果我们正在测试的代码带有一个MailMan对象,那么我们的测试代码可以调用它的唯一方法就是提供一个实际的MailMan实例。

1
func SendWelcomeEmail(m *MailMan, to ...*mail.Address) {...}

这意味着只要我们运行测试,就可以发送真实的电子邮件。想象一下,如果我们从上面实现了保存功能。我们会很快惹恼我们的测试用户或产生巨额服务费用。 一种替代方法是将此简单接口添加到您的代码中:

1
2
3
type EmailSender interface{
  Send(subject, body string, to ...*mail.Address)
}

当然,由于我们首先获得了来自他的“发送”方法签名,因此“MailMan”已经满足了该接口的要求,因此我们仍然可以像以前一样传递“ MailMan”对象。

但是现在我们可以编写一个测试电子邮件发件人:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type testEmailSender struct{
  lastSubject string
  lastBody    string
  lastTo      []*mail.Address
}
// make sure it satisfies the interface
var _ package.EmailSender = (*testEmailSender)(nil)
func (t *testEmailSender) Send(subject, body string, to ...*mail.Address) {
  t.lastSubject = subject
  t.lastBody = body
  t.lastTo = to
}

现在,我们可以更新SendWelcomeEmail函数以采用接口,而不是具体类型:

1
func SendWelcomeEmail(m EmailSender, to ...*mail.Address) {...}

在我们的测试代码中,我们可以改为发送伪造的发送者,并在调用目标函数后在字段上声明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func TestSendWelcomeEmail(t *testing.T) {
  sender := &testEmailSender{}
  SendWelcomeEmail(sender, to1, to2)
  if sender.lastSubject != "Welcome" {
    t.Error("Subject line was wrong")
  }
  if sender.To[0] != to1 && sender.To[1] != to2 {
    t.Error("Wrong recipients")
  }
}

导出未导出方法进行测试

在Go中,导出的标识符以大写字母开头,而小写的标识符只能在自己的包中看到。我们应该只将实际需要的标识符导出到外部,并让我们的API易于使用和维护。

实际上,Go Test只是将新文件写在测试目标之外,并带有_test后缀。例如,我们要测试sum.go,我们应该创建一个测试文件和sum_test.go并在其中编写测试。只需查看https://golang.org/pkg/testing/

在xx_test,我们有两个选择。一,使用与目标对象相同的软件包(例如,测试数学并使用数学)。或者,使用包带_test测试(如测试数学与math_test)。

在大多数情况下,我们应该将稍后的代码与_test(测试代码)一起使用,就像带有一些小窗口的黑盒子一样。因此,我们无法访问未导出的标识符。但是在需要时我们如何在一些特殊的测试用例中访问它们呢???

Export_test.go

让我们回顾一下我们需要什么:

  • 产品代码中没有导出的未导出标识符
  • 在测试代​​码中导出其中一些

解决方法很简单。

例如,我们要使用软件包math测试sum.go中的sum函数

1
2
3
4
5
package math

func sum(a, b int) int {
  return a + b
}

并在sum_test包中创建一个名为sum_test.go的测试文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package math_test

import (
	"testing"
	"xx/math"
)

func TestName(t *testing.T) {
	if 2 != math.Sum(1, 1) {
		t.Fatalf("Sum of %d + %d not 2")
	}
}

但是…在math中,sum不是Sum,所以这里的诀窍是我们应该在数学包中创建另一个测试文件(在这里export_test.go)

1
2
3
4
package math

// Export for testing.
var Math = math

export_test.go仅在我们运行go test时包含在内,因此不会污染您的API,并且用户从不访问它们(不像java的@VisibleForTesting),并且它搭建了一个桥,让未导出的桥可在math_test中访问

避免接口污染

我们不能撇开接口谈 Go 的测试。

接口在测试的上下文中十分重要,因为对于测试来讲,接口是一种十分有力的工具,所以正确的使用他变得尤为重要。一个包经常会导出接口给用户,用户可以使用包中预定义的接口实现,也可以自己为该接口定义实现。

1
2
3
The bigger the interface, the weaker the abstraction.

Rob Pike, Go Proverbs

在导出一个接口的时候我们需要很谨慎的考虑是否应该导出它。开发者为了能让用户可以自定义接口的实现往往选择导出这个接口。然而我们并不需要这样,你只需要在你的结构体中实现了这个接口的行为,就可以在需要该接口的地方使用这个结构体。这样,你的代码包和用户的代码包就不会有强制的依赖关系。一个很好的例子就是 errors package ( error 接口没有被导出,但是我们可以在任何包中都可以定义自己的实现)。

如果你不想导出一个接口,那么可以使用 internal/package subtree 来保证接口只有在包内可见。这样我们就不用担心用户会依赖这个接口,也可以在新的需求出现的时候,十分灵活的修改这个接口。我们经常在使用一个外部依赖的时候创建接口,并使用依赖注入的方式把这个外部依赖作为这个接口的实现,这样我们就可以排除外部依赖的因素而只测试自己的代码。这让用户只需要封装代码库中自己使用的那一小部分。

更多详情,https://rakyll.org/interface-pollution/

Test fixtures

这个技巧在标准库 中用到。这是我从 Mitchell Hashimoto 和 Dave Cheney 的作品中学到的。go test 很好地支持从文件中加载测试数据。第一,go build 忽略名为 testdata 的文件夹。第二,当 ge test 运行时,它将当前目录设置为包目录。这使得你可以使用相对路径 testdata 目录作为存储和加载数据的地方。

1
2
3
4
5
|____controller
| |____testdata
| | |____hello.txt
| |____upload.go
| |____upload_test.go
1
2
3
path := "testdata/hello.txt"//要上传文件所在路径
file, _ := os.Open(path)
defer file.Close()

Golden 文件

这个技巧也在标准库 中被用到,但我是从 Mitchell Hashimoto 的演讲中学到的。这里的思想是将期望输出存储在一个名为 .golden 的文件中并提供一个 flag 来更新它。这里是例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var update = flag.Bool("update", false, "update .golden files")
func TestSomething(t *testing.T) {
    actual := doSomething()
    Golden := filepath.Join("testdata", tc.Name+ ".golden" )
    if *update {
        ioutil.WriteFile(golden, actual, 0644)
    }
    expected, _ := ioutil.ReadFile(golden)

    if !bytes.Equal(actual, expected) {
        // FAIL!
    }
}

这个技巧使你得以测试复杂的输出而无需硬编码。

不要导出并发原语

Go 提供了非常易于使用的并发原语,这也导致了它被过度的使用。我们主要担心的是 channel 和 sync package 。有的时候我们会导出一个 channel 给用户使用。另外一个常见的错误就是在使用 sync.Mutex 作为结构体字段的时候没有把它设置成私有。这并不总是很糟糕,不过在写测试的时候却需要考虑的更加全面。

当我们导出 channel 的时候我们就为这个包的用户带来了测试上的一些不必要的麻烦。你每导出一个 channel 就是在提高用户在测试时候的难度。为了写出正确的测试,用户必须考虑这些:

  • 什么时候数据发送完成。
  • 在接受数据的时候是否会发生错误。
  • 如果需要在包中清理使用过的channel的时候该怎么做。
  • 如何将 API 封装成一个接口,使我们不用直接去调用它。

请看下面这个在队列中读取数据的例子。这个库导出了一个 channel 用来让用户读取他的数据。

1
2
type Reader struct {...}
func (r *Reader) ReadChan() <-chan Msg {...}

现在有一个使用你的库的用户想写一个测试程序。

1
2
3
4
5
6
7
8
func TestConsumer(t testing.T) {
    cons := &Consumer{
        r: libqueue.NewReader(),
    }
    for msg := range cons.r.ReadChan() {
        // Test thing.
    }
}

用户可能会认为使用依赖注入是一种好的方式。并且用下面的方式实现了自己的队列:

1
2
3
4
5
6
7
8
func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    for msg := range cons.r.ReadChan() {
        // Test thing.
    }
}

这时有一个潜在的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    for {
        select {
        case msg := <-cons.r.ReadChan():
            // Test thing.
        case err := <-cons.r.ErrChan():
            // What caused this again?
        }
    }
}

现在我们不知道如何来向这个 channel 中插入数据,来模拟使用时这个代码库的真实运行情况,如果这个库提供了一个同步的API接口,那么我们可以并发的调用它,这样测试就会非常的简单。

1
2
3
4
5
6
7
func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    msg, err := cons.r.ReadMsg()
    // handle err, test thing
}

当你有疑问的时候,一定要记住在用户的包中使用 goroutine 是很简单的事情,但是你的包一旦导出了就很难被移除。所以一定别忘了在 package 的文档中注明这个包是不是多 goroutine 并发安全的。

有时,我们不可避免的需要导出一个 channel。为了减少这样带来的问题,你可以通过导出只读的 channel(<-chan) 或者只写的 channel(chan<-) 来替代直接导出一个 channel。

参考

Go 中的进阶测试模式 Golang Trick: Export unexport method for test Go 中的 5 种高级测试方法