Learn Go with tests 学习笔记(9)——Mocking

Mocking

现在要写一个倒计时程序,从3开始,每隔一秒显示下一个数字,在0时打印"Go!"

3
2
1
Go!

这个任务看起来很微不足道,但是仍然需要使用迭代的TDD方法来进行开发。迭代在这里的意思是take the smallest steps we can to have useful software。如果单纯靠长时间闷头敲代码来得到理论上能用的程序,我们就会很容易掉到坑里。能够把你的需求切成要多小有多小的切片从而得到起作用的软件是一项很重要的技能。现在我们就要把手头的任务分步进行:

  1. 打印"3"
  2. 打印"3""2""1"“Go!”
  3. 在每一行中间等待1秒

需求一

我们的程序需要把信息打印到os.Stdout,我们之前已经学习了使用DI来辅助测试打印的信息。由于打印出来的信息通过实际上是被送到了实现了io.Writer接口的os.Stdout处,我们测试的时候想要capture这个信息是否被正确地generated出来就需要使用另一个实现了io.Writer接口的bytes.Buffer来装载需要打印的信息。与此同时在实际使用Countdown函数的main函数中,我们还是需要把数据放到os.Stdout中,这样数据才会被打印出来。

func TestCountdown(t *testing.T) {
	buffer := &bytes.Buffer{}

	Countdown(buffer)

	got := buffer.String()
	want := "3"

	if got != want {
		t.Errorf("got %q want %q", got, want)
	}
}

现在我们来实现我们的Countdown函数来使现阶段的test通过。注意这里的参数列表里我们使用了io.Writer接口而不使用*bytes.Buffer正是呼应了上一段内容里面提到的在不同的地方使用不同的实现了io.Writer接口的数据类型的需求。

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

需求二、三

需求2的test里,需要知道的新知识就是,反引号``可以帮助我们输入带有换行符的字符串。需求三需要添加延迟函数,这里需要用到time包里的Sleep函数

const finalWord = "Go!"
const countdownStart = 3

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

Mocking

现在遇到一个问题,由于设置的延时,使得我们的测试花费了三秒多

  • 任何前沿的软件开发思想都强调了快速反馈的重要性
  • 缓慢的测试会影响开发效率
  • 假如我们的需求变复杂了,并且需要进行更多的测试。想象一下测试中每次调用Countdown函数我们都要等上三秒。

还有另一个问题,我们没有对程序是否进行了正确的延时进行测试。这就要求我们提取对Sleep的依赖。我们的办法就是模拟(mock)出time.Sleep,这样我们就可以使用DI来代替真正的time.Sleep(类比os.Stdout),从而实现对Sleep调用的监视并且做出断言。

首先定义依赖为接口。This lets us then use a real Sleeper in main and a spy sleeper in our tests. 通过使用接口我们的Countdown函数就会不管我们究竟用了哪一种Sleeper,这样我们的调用就具有了flexibility。

type Sleeper interface {
	Sleep()
}

Now we need to make a mock of it for our tests to use.现在我们需要使用我们刚刚创建的接口来mock我们的time.SleepSpies are a kind of mock which can record how a dependency is used. They can record the arguments sent in, how many times it has been called, etc. In our case, we’re keeping track of how many times Sleep() is called so we can check it in our test.

type SpySleeper struct {
	Calls int
}

func (s *SpySleeper) Sleep() {//通过实现Sleep方法,实现了Sleeper接口,但是没有真正地Sleep而是计数
	s.Calls++
}

现在我们就可以使用刚刚mock出来的SpySleeper来更新我们的test。我们要做的就是把依赖注入进去,然后断言程序是否sleep了3次。

func TestCountdown(t *testing.T) {
	buffer := &bytes.Buffer{}
	spySleeper := &SpySleeper{}

	Countdown(buffer, spySleeper)//这里Countdown多出了一个参数,实现了我们的依赖注入,同时我们也需要在Countdown的声明里添加类型为Sleeper的sleeper参数

	got := buffer.String()
	want := `3
2
1
Go!`

	if got != want {
		t.Errorf("got %q want %q", got, want)
	}

	if spySleeper.Calls != 3 {
		t.Errorf("not enough calls to sleeper, want 3 got %d", spySleeper.Calls)
	}
}

为了让main函数正常运行,在修改完Countdown函数的定义之后,我们还需要修改main函数传入的参数,这里需要创建一个真正能够sleep的Sleeper

type DefaultSleeper struct{}

func (d *DefaultSleeper) Sleep() {//通过实现Sleep方法,实现了Sleeper接口,并且能够真正地sleep
	time.Sleep(1 * time.Second)
}

func main() {
	sleeper := &DefaultSleeper{}
	Countdown(os.Stdout, sleeper)
}

现在我们已经有了一个真正的Sleeper,他内部的Sleep方法通过time.Sleep进行时延,所以这时候我们就要修改一下Countdown函数,使用我们封装好的方法来进行时延,而不是直接调用time.Sleep

func Countdown(out io.Writer, sleeper Sleeper) {
	for i := countdownStart; i > 0; i-- {
		fmt.Fprintln(out, i)
		sleeper.Sleep()//不论是mock测试还是实际运行,都是调用接口的Sleep方法,十分方便,这样我们的测试也不用再花费3秒,实际运行也可以正常延时
	}

	fmt.Fprint(out, finalWord)
}

一石二鸟

我们虽然对程序是否延时3次进行了测试,但是仍然有一件事不明朗,那就是我们无法通过现有的测试去证明延时发生在了正确的时间。现在我们要干一件很牛逼的事情来解决这个问题,那就是同时对os.Stdout以及time.Sleep进行mock!

Let’s use spying again with a new test to check the order of operations is correct. We have two different dependencies and we want to record all of their operations into one list. So we’ll create one spy for them both.

const write = "write"
const sleep = "sleep"

type SpyCountdownOperations struct {
	Calls []string
}

func (s *SpyCountdownOperations) Sleep() {
	s.Calls = append(s.Calls, sleep)
}

func (s *SpyCountdownOperations) Write(p []byte) (n int, err error) {
	s.Calls = append(s.Calls, write)
	return
}

通过上述代码为SpyCountdownOperations定义了两个方法从而同时实现了Sleep接口和io.Writer接口,并且其功能是对Sleep和Write两个动作进行mock,记录每一次调用。这样一来我们避免了时间的浪费,二来可以记录每次操作的顺序,确保延时发生在正确的时间点。接下来我们就可以写一个子测试了。同时我们也可以删掉第一个Spy,它的功能完全可以由我们新写的Spy代替。

t.Run("sleep before every print", func(t *testing.T) {
	spySleepPrinter := &SpyCountdownOperations{}
	Countdown(spySleepPrinter, spySleepPrinter)

	want := []string{
		write,
		sleep,
		write,
		sleep,
		write,
		sleep,
		write,
	}

	if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
		t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
	}
})

功能拓展

程序的功能越完善越好。我们想要为倒计时添加新的特性,那就是可以自定义每段延时的时间。思考一下,我们Countdown函数的参数列表里已经提供了一个Sleeper接口可以传入,那么我们只要创建一个新的能够实现Sleeper接口的结构体,并且内部包含自定义的延时信息就可以完成这项功能。需要注意的是在这个新的结构体内部我们不仅需要一个field来存放延时参数,还需要一个sleep function作为延时参数的入口。

type ConfigurableSleeper struct {
	duration time.Duration//打开源码发现time.Duration就是int64的重定义
	sleep    func(time.Duration)
}

需要注意的是在实现Sleeper接口的时候我们不用指明内部的c.sleep是什么函数,这样做也是为了给我们在测试和实际使用中提供自由的选择。

func (c *ConfigurableSleeper) Sleep() {
    c.sleep(c.duration)//func(duration)
}

接下来在测试代码中添加新的Spy结构体来对sleep动作进行mock。

type SpyTime struct {
	durationSlept time.Duration
}

func (s *SpyTime) Sleep(duration time.Duration) {//这实际上具体化了上面提到的c.sleep函数,也就是说在测试中,c.sleep就是s.Sleep
	s.durationSlept = duration
}

func TestConfigurableSleeper(t *testing.T) {
	sleepTime := 5 * time.Second

	spyTime := &SpyTime{}
	sleeper := ConfigurableSleeper{sleepTime, spyTime.Sleep}//mock
	sleeper.Sleep()

	if spyTime.durationSlept != sleepTime {
		t.Errorf("should have slept for %v but slept for %v", sleepTime, spyTime.durationSlept)
	}
}

然而在main函数中,我们就需要真正地去进行延时了。那我们就不能把mock出来的方法传进去,而要传入真正地time.Sleep作为c.sleep函数。

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

Since we are using the ConfigurableSleeper, it is now safe to delete the DefaultSleeper implementation. Wrapping up our program and having a more generic Sleeper with arbitrary long countdowns.

反思

可能你会听说mock测试有它的弊端。当人们不去倾听test,不去尊重重构的时候,事情往往变得很糟。如果你的mocking代码变得很复杂,或者你不得不mock out很多不同的东西来进行测试,你应该记住这种糟糕的感觉,然后反思自己的代码。通常这种情况表示:

  • The thing you are testing is having to do too many things (because it has too many dependencies to mock)
    • 解决方法是把任务分成许多module,这样就可以少做点事
  • Its dependencies are too fine-grained
    • 考虑如何将这些依赖项合并到一个有意义的模块中
  • Your test is too concerned with implementation details
    • 更加倾向于测试预期的行为,而不是功能的实现

Normally a lot of mocking points to bad abstraction in your code.人们在这里看到的是测试驱动开发的弱点,但它实际上是一种力量,通常情况下,糟糕的测试代码是糟糕设计的结果,而设计良好的代码很容易测试。

曾经遇到过这种情况吗?

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

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

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

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

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

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

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

洞爷湖dyh

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值