Mocking

你可以在这里找到本章的所有代码

现在需要你写一个程序,从 3 开始依次向下,当到 0 时打印 「GO!」 并退出,要求每次打印从新的一行开始且打印间隔一秒的停顿。

  1. 3
  2. 2
  3. 1
  4. Go!

我们将通过编写一个 Countdown 函数来处理这个问题,然后放入 main 程序,所以它看起来这样:

  1. package main
  2. func main() {
  3. Countdown()
  4. }

虽然这是一个非常简单的程序,但要完全测试它,我们需要像往常一样采用迭代的、测试驱动的方法。

所谓迭代是指:确保我们采取最小的步骤让软件可用。

我们不想花太多时间写那些在被攻击后理论上还能运行的代码,因为这经常导致开发人员陷入开发的无底深渊。尽你所能拆分需求是一项很重要的技能,这样你就能拥有可以工作的软件

下面是我们如何划分工作和迭代的方法:

  • 打印 3
  • 打印 3 到 Go!
  • 在每行中间等待一秒

先写测试

我们的软件需要将结果打印到标准输出界面。在 DI(依赖注入) 的部分,我们已经看到如何使用 DI 进行方便的测试。

  1. func TestCountdown(t *testing.T) {
  2. buffer := &bytes.Buffer{}
  3. Countdown(buffer)
  4. got := buffer.String()
  5. want := "3"
  6. if got != want {
  7. t.Errorf("got '%s' want '%s'", got, want)
  8. }
  9. }

如果你对 buffer 不熟悉,请重新阅读前面的部分。

我们清楚,我们的目的是让 Countdown 函数将数据写到某处,io.writer 就是作为 Go 的一个接口来抓取数据的一种方式。

  • main 中,我们将信息发送到 os.Stdout,所以用户可以看到 Countdown 的结果打印到终端
  • 在测试中,我们将发送到 bytes.Buffer,所以我们的测试能够抓取到正在生成的数据

尝试并运行测试

./countdown_test.go:11:2: undefined: Countdown

为测试的运行编写最少量的代码,并检查失败测试的输出

定义 Countdown 函数

  1. func Countdown() {}

再次尝试运行

  1. ./countdown_test.go:11:11: too many arguments in call to Countdown
  2. have (*bytes.Buffer)
  3. want ()

编译器正在告诉你函数的问题,所以更正它

  1. func Countdown(out *bytes.Buffer) {}

countdown_test.go:17: got '' want '3'

这样结果就完美了!

编写足够的代码使程序通过

  1. func Countdown(out *bytes.Buffer) {
  2. fmt.Fprint(out, "3")
  3. }

我们正在使用 fmt.Fprint 传入一个 io.Writer(例如 *bytes.Buffer)并发送一个 string。这个测试应该可以通过。

重构代码

虽然我们都知道 *bytes.Buffer 可以运行,但最好使用通用接口代替。

  1. func Countdown(out io.Writer) {
  2. fmt.Fprint(out, "3")
  3. }

重新运行测试他们应该就可以通过了。

为了完成任务,现在让我们将函数应用到 main中。这样的话,我们就有了一些可工作的软件来确保我们的工作正在取得进展。

  1. package main
  2. import (
  3. "fmt"
  4. "io"
  5. "os"
  6. )
  7. func Countdown(out io.Writer) {
  8. fmt.Fprint(out, "3")
  9. }
  10. func main() {
  11. Countdown(os.Stdout)
  12. }

尝试运行程序,这些成果会让你感到神奇。

当然,这仍然看起来很简单,但是我建议任何项目都使用这种方法。在测试的支持下,将功能切分成小的功能点,并使其首尾相连顺利的运行。

接下来我们可以让它打印 2,1 然后输出「Go!」。

先写测试

通过花费一些时间让整个流程正确执行,我们就可以安全且轻松的迭代我们的解决方案。我们将不再需要停止并重新运行程序,要对它的工作充满信心因为所有的逻辑都被测试过了。

  1. func TestCountdown(t *testing.T) {
  2. buffer := &bytes.Buffer{}
  3. Countdown(buffer)
  4. got := buffer.String()
  5. want := `3
  6. 2
  7. 1
  8. Go!`
  9. if got != want {
  10. t.Errorf("got '%s' want '%s'", got, want)
  11. }
  12. }

反引号语法是创建 string 的另一种方式,但是允许你放置东西例如放到新的一行,对我们的测试来说是完美的。

尝试并运行测试

  1. countdown_test.go:21: got '3' want '3
  2. 2
  3. 1
  4. Go!'

写足够的代码令测试通过

  1. func Countdown(out io.Writer) {
  2. for i := 3; i > 0; i-- {
  3. fmt.Fprintln(out, i)
  4. }
  5. fmt.Fprint(out, "Go!")
  6. }

使用 for 循环与 i-- 反向计数,并且用 fmt.println 打印我们的数字到 out,后面跟着一个换行符。最后用 fmt.Fprint 发送 「Go!」。

重构代码

这里已经没有什么可以重构的了,只需要将变量重构为命名常量。

  1. const finalWord = "Go!"
  2. const countdownStart = 3
  3. func Countdown(out io.Writer) {
  4. for i := countdownStart; i > 0; i-- {
  5. fmt.Fprintln(out, i)
  6. }
  7. fmt.Fprint(out, finalWord)
  8. }

如果你现在运行程序,你应该可以获得想要的输出,但是向下计数的输出没有 1 秒的暂停。

Go 可以通过 time.Sleep 实现这个功能。尝试将其添加到我们的代码中。

  1. func Countdown(out io.Writer) {
  2. for i := countdownStart; i > 0; i-- {
  3. time.Sleep(1 * time.Second)
  4. fmt.Fprintln(out, i)
  5. }
  6. time.Sleep(1 * time.Second)
  7. fmt.Fprint(out, finalWord)
  8. }

如果你运行程序,它会以我们期望的方式工作。

Mocking

测试可以通过,软件按预期的工作。但是我们有一些问题:

  • 我们的测试花费了 4 秒的时间运行
    • 每一个关于软件开发的前沿思考性文章,都强调快速反馈循环的重要性。
    • 缓慢的测试会破坏开发人员的生产力。
    • 想象一下,如果需求变得更复杂,将会有更多的测试。对于每一次新的 Countdown 测试,我们是否会对被添加到测试运行中 4 秒钟感到满意呢?
  • 我们还没有测试这个函数的一个重要属性。

我们有个 Sleeping 的注入,需要抽离出来然后我们才可以在测试中控制它。

如果我们能够 mock time.Sleep,我们可以用 依赖注入 的方式去来代替「真正的」time.Sleep,然后我们可以使用断言 监视调用

先写测试

让我们将依赖关系定义为一个接口。这样我们就可以在 main 使用 真实的 Sleeper,并且在我们的测试中使用 spy sleeper。通过使用接口,我们的 Countdown 函数忽略了这一点,并为调用者增加了一些灵活性。

  1. type Sleeper interface {
  2. Sleep()
  3. }

我做了一个设计的决定,我们的 Countdown 函数将不会负责 sleep 的时间长度。这至少简化了我们的代码,也就是说,我们函数的使用者可以根据喜好配置休眠的时长。

现在我们需要为我们使用的测试生成它的 mock

  1. type SpySleeper struct {
  2. Calls int
  3. }
  4. func (s *SpySleeper) Sleep() {
  5. s.Calls++
  6. }

监视器(spies)是一种 mock,它可以记录依赖关系是怎样被使用的。它们可以记录被传入来的参数,多少次等等。在我们的例子中,我们跟踪记录了 Sleep() 被调用了多少次,这样我们就可以在测试中检查它。

更新测试以注入对我们监视器的依赖,并断言 sleep 被调用了 4 次。

  1. func TestCountdown(t *testing.T) {
  2. buffer := &bytes.Buffer{}
  3. spySleeper := &SpySleeper{}
  4. Countdown(buffer, spySleeper)
  5. got := buffer.String()
  6. want := `3
  7. 2
  8. 1
  9. Go!`
  10. if got != want {
  11. t.Errorf("got '%s' want '%s'", got, want)
  12. }
  13. if spySleeper.Calls != 4 {
  14. t.Errorf("not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
  15. }
  16. }

尝试并运行测试

  1. too many arguments in call to Countdown
  2. have (*bytes.Buffer, Sleeper)
  3. want (io.Writer)

为测试的运行编写最少量的代码,并检查失败测试的输出

我们需要更新 Countdow 来接受我们的 Sleeper

  1. func Countdown(out io.Writer, sleeper Sleeper) {
  2. for i := countdownStart; i > 0; i-- {
  3. time.Sleep(1 * time.Second)
  4. fmt.Fprintln(out, i)
  5. }
  6. time.Sleep(1 * time.Second)
  7. fmt.Fprint(out, finalWord)
  8. }

如果您再次尝试,你的 main 将不会出现相同编译错误的原因

  1. ./main.go:26:11: not enough arguments in call to Countdown
  2. have (*os.File)
  3. want (io.Writer, Sleeper)

让我们创建一个 真正的 sleeper 来实现我们需要的接口

  1. type ConfigurableSleeper struct {
  2. duration time.Duration
  3. }
  4. func (o *ConfigurableSleeper) Sleep() {
  5. time.Sleep(o.duration)
  6. }

我决定做点额外的努力,让它成为我们真正的可配置的 sleeper。但你也可以在 1 秒内毫不费力地编写它。

我们可以在实际应用中使用它,就像这样:

  1. func main() {
  2. sleeper := &ConfigurableSleeper{1 * time.Second}
  3. Countdown(os.Stdout, sleeper)
  4. }

足够的代码令测试通过

现在测试正在编译但是没有通过,因为我们仍然在调用 time.Sleep 而不是依赖注入。让我们解决这个问题。

  1. func Countdown(out io.Writer, sleeper Sleeper) {
  2. for i := countdownStart; i > 0; i-- {
  3. sleeper.sleep()
  4. fmt.Fprintln(out, i)
  5. }
  6. sleeper.sleep()
  7. fmt.Fprint(out, finalWord)
  8. }

测试应该可以该通过,并且不再需要 4 秒。

仍然还有一些问题

还有一个重要的特性,我们还没有测试过。

Countdown 应该在第一个打印之前 sleep,然后是直到最后一个前的每一个,例如:

  • Sleep
  • Print N
  • Sleep
  • Print N-1
  • Sleep
  • etc

我们最新的修改只断言它已经 sleep 了 4 次,但是那些 sleeps 可能没按顺序发生。

当你在写测试的时候,如果你没有信心,你的测试将给你足够的信心,尽管推翻它!(不过首先要确定你已经将你的更改提交给了源代码控制)。将代码更改为以下内容。

  1. func Countdown(out io.Writer, sleeper Sleeper) {
  2. for i := countdownStart; i > 0; i-- {
  3. sleeper.Sleep()
  4. }
  5. for i := countdownStart; i > 0; i-- {
  6. fmt.Fprintln(out, i)
  7. }
  8. sleeper.Sleep()
  9. fmt.Fprint(out, finalWord)
  10. }

如果你运行测试,它们仍然应该通过,即使实现是错误的。

让我们再用一种新的测试来检查操作的顺序是否正确。

我们有两个不同的依赖项,我们希望将它们的所有操作记录到一个列表中。所以我们会为它们俩创建 同一个监视器

  1. type CountdownOperationsSpy struct {
  2. Calls []string
  3. }
  4. func (s *CountdownOperationsSpy) Sleep() {
  5. s.Calls = append(s.Calls, sleep)
  6. }
  7. func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
  8. s.Calls = append(s.Calls, write)
  9. return
  10. }
  11. const write = "write"
  12. const sleep = "sleep"

我们的 CountdownOperationsSpy 同时实现了 io.writerSleeper,把每一次调用记录到 slice。在这个测试中,我们只关心操作的顺序,所以只需要记录操作的代名词组成的列表就足够了。

现在我们可以在测试套件中添加一个子测试。

  1. t.Run("sleep after every print", func(t *testing.T) {
  2. spySleepPrinter := &CountdownOperationsSpy{}
  3. Countdown(spySleepPrinter, spySleepPrinter)
  4. want := []string{
  5. sleep,
  6. write,
  7. sleep,
  8. write,
  9. sleep,
  10. write,
  11. sleep,
  12. write,
  13. }
  14. if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
  15. t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
  16. }
  17. })

现在这个测试应该会失败。恢复原状新测试应该又可以通过。

我们现在在 Sleeper 上有两个测试监视器,所以我们现在可以重构我们的测试,一个测试被打印的内容,另一个是确保我们在打印时间 sleep。最后我们可以删除第一个监视器,因为它已经不需要了。

  1. func TestCountdown(t *testing.T) {
  2. t.Run("prints 3 to Go!", func(t *testing.T) {
  3. buffer := &bytes.Buffer{}
  4. Countdown(buffer, &CountdownOperationsSpy{})
  5. got := buffer.String()
  6. want := `3
  7. 2
  8. 1
  9. Go!`
  10. if got != want {
  11. t.Errorf("got '%s' want '%s'", got, want)
  12. }
  13. })
  14. t.Run("sleep after every print", func(t *testing.T) {
  15. spySleepPrinter := &CountdownOperationsSpy{}
  16. Countdown(spySleepPrinter, spySleepPrinter)
  17. want := []string{
  18. sleep,
  19. write,
  20. sleep,
  21. write,
  22. sleep,
  23. write,
  24. sleep,
  25. write,
  26. }
  27. if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
  28. t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
  29. }
  30. })
  31. }

我们现在有了自己的函数,并且它的两个重要的属性已经通过合理的测试。

难道 mocking 不是在作恶(evil)吗?

你可能听过 mocking 是在作恶。就像软件开发中的任何东西一样,它可以被用来作恶,就像 DRY(Don’t repeat yourself) 一样。

当人们 不听从他们的测试 并且 不尊重重构阶段时,他们通常会陷入糟糕的境地。

如果你的模拟代码变得很复杂,或者你需要模拟很多东西来测试一些东西,那么你应该 倾听 那种糟糕的感觉,并考虑你的代码。通常这是一个征兆:

  • 你正在进行的测试需要做太多的事情
    • 把模块分开就会减少测试内容
  • 它的依赖关系太细致
    • 考虑如何将这些依赖项合并到一个有意义的模块中
  • 你的测试过于关注实现细节
    • 最好测试预期的行为,而不是功能的实现

通常,在你的代码中有大量的 mocking 指向 错误的抽象

人们在这里看到的是测试驱动开发的弱点,但它实际上是一种力量,通常情况下,糟糕的测试代码是糟糕设计的结果,而设计良好的代码很容易测试。

但是模拟和测试仍然让我举步维艰!

曾经遇到过这种情况吗?

  • 你想做一些重构
  • 为了做到这一点,你最终会改变很多测试
  • 你对测试驱动开发提出质疑,并在媒体上发表一篇文章,标题为「Mocking 是有害的」

这通常是您测试太多 实现细节 的标志。尽力克服这个问题,所以你的测试将测试 有用的行为,除非这个实现对于系统运行非常重要。

有时候很难知道到底要测试到 什么级别,但是这里有一些我试图遵循的思维过程和规则。

  • 重构的定义是代码更改,但行为保持不变。 如果您已经决定在理论上进行一些重构,那么你应该能够在没有任何测试更改的情况下进行提交。所以,在写测试的时候问问自己。
    • 我是在测试我想要的行为还是实现细节?
    • 如果我要重构这段代码,我需要对测试做很多修改吗?
  • 虽然 Go 允许你测试私有函数,但我将避免它作为私有函数与实现有关。
  • 我觉得如果一个测试 超过 3 个模拟,那么它就是警告 —— 是时候重新考虑设计。
  • 小心使用监视器。监视器让你看到你正在编写的算法的内部细节,这是非常有用的,但是这意味着你的测试代码和实现之间的耦合更紧密。如果你要监视这些细节,请确保你真的在乎这些细节。

和往常一样,软件开发中的规则并不是真正的规则,也有例外。Uncle Bob 的文章 「When to mock」 有一些很好的指南。

总结

更多关于测试驱动开发的方法

  • 当面对不太简单的例子,把问题分解成「简单的模块」。试着让你的工作软件尽快得到测试的支持,以避免掉进兔子洞(rabbit holes,意指未知的领域)和采取「最终测试(Big bang)」的方法。
  • 一旦你有一些正在工作的软件,小步迭代 应该是很容易的,直到你实现你所需要的软件。

Mocking

  • 没有对代码中重要的区域进行 mock 将会导致难以测试。在我们的例子中,我们不能测试我们的代码在每个打印之间暂停,但是还有无数其他的例子。调用一个 可能 失败的服务?想要在一个特定的状态测试您的系统?在不使用 mocking 的情况下测试这些场景是非常困难的。
  • 如果没有 mock,你可能需要设置数据库和其他第三方的东西来测试简单的业务规则。你可能会进行缓慢的测试,从而导致 缓慢的反馈循环
  • 当不得不启用一个数据库或者 webservice 去测试某个功能时,由于这种服务的不可靠性,你将会得到的是一个 脆弱的测试

一旦开发人员学会了 mocking,就很容易对系统的每一个方面进行过度测试,按照 它工作的方式 而不是 它做了什么。始终要注意 测试的价值,以及它们在将来的重构中会产生什么样的影响。

在这篇关于 mocking 的文章中,我们只提到了 监视器(Spies),他们是一种 mock。也有不同类型的 mocks。Uncle Bob 的一篇极易阅读的文章中解释了这些类型。在后面的章节中,我们将需要编写依赖于其他数据的代码,届时我们将展示 Stubs 行为。


作者:Chris James译者:Donng校对:rxcai

本文由 GCTT 原创编译,Go 中文网 荣誉推出