Mocking
现在需要你写一个程序,从 3 开始依次向下,当到 0 时打印 「GO!」 并退出,要求每次打印从新的一行开始且打印间隔一秒的停顿。
3
2
1
Go!
我们将通过编写一个
Countdown
函数来处理这个问题,然后放入 main
程序,所以它看起来这样:package main
func main() {
Countdown()
}
虽然这是一个非常简单的程序,但要完全测试它,我们需要像往常一样采用迭代的、测试驱动的方法。
所谓迭代是指:确保我们采取最小的步骤让软件可用。
我们不想花太多时间写那些在被攻击后理论上还能运行的代码,因为这经常导致开发人员陷入开发的无底深渊。尽你所能拆分需求是一项很重要的技能,这样你就能拥有可以工作的软件。
下面是我们如何划分工作和迭代的方法:
- 打印 3
- 打印 3 到 Go!
- 在每行中间等待一秒
我们的软件需要将结果打印到标准输出界面。在 DI(依赖注入) 的部分,我们已经看到如何使用 DI 进行方便的测试。
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer)
got := buffer.String()
want := "3"
if got != want {
t.Errorf("got '%s' want '%s'", got, want)
}
}
如果你对
buffer
不熟悉,请重新阅读前面的部分。我们清楚,我们的目的是让
Countdown
函数将数据写到某处,io.writer
就是作为 Go 的一个接口来抓取数据的一种方式。- 在
main
中,我们将信息发送到os.Stdout
,所以用户可以看到Countdown
的结果打印到终端 - 在测试中,我们将发送到
bytes.Buffer
,所以我们的测试能够抓取到正在生成的数据
./countdown_test.go:11:2: undefined: Countdown
定义
Countdown
函数func Countdown() {}
再次尝试运行
./countdown_test.go:11:11: too many arguments in call to Countdown
have (*bytes.Buffer)
want ()
编译器正在告诉你函数的问题,所以更正它
func Countdown(out *bytes.Buffer) {}
countdown_test.go:17: got '' want '3'
这样结果就完美了!
func Countdown(out *bytes.Buffer) {
fmt.Fprint(out, "3")
}
我们正在使用
fmt.Fprint
传入一个 io.Writer
(例如 *bytes.Buffer
)并发送一个 string
。这个测试应该可以通过。虽然我们都知道
*bytes.Buffer
可以运行,但最好使用通用接口代替。func Countdown(out io.Writer) {
fmt.Fprint(out, "3")
}
重新运行测试他们应该就可以通过了。
为了完成任务,现在让我们将函数应用到
main
中。这样的话,我们就有了一些可工作的软件来确保我们的工作正在取得进展。package main
import (
"fmt"
"io"
"os"
)
func Countdown(out io.Writer) {
fmt.Fprint(out, "3")
}
func main() {
Countdown(os.Stdout)
}
尝试运行程序,这些成果会让你感到神奇。
当然,这仍然看起来很简单,但是我建议任何项目都使用这种方法。在测试的支持下,将功能切分成小的功能点,并使其首尾相连顺利的运行。
接下来我们可以让它打印 2,1 然后输出「Go!」。
通过花费一些时间让整个流程正确执行,我们就可以安全且轻松的迭代我们的解决方案。我们将不再需要停止并重新运行程序,要对它的工作充满信心因为所有的逻辑都被测试过了。
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer)
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got '%s' want '%s'", got, want)
}
}
反引号语法是创建
string
的另一种方式,但是允许你放置东西例如放到新的一行,对我们的测试来说是完美的。countdown_test.go:21: got '3' want '3
2
1
Go!'
func Countdown(out io.Writer) {
for i := 3; i > 0; i-- {
fmt.Fprintln(out, i)
}
fmt.Fprint(out, "Go!")
}
使用
for
循环与 i--
反向计数,并且用 fmt.println
打印我们的数字到 out
,后面跟着一个换行符。最后用 fmt.Fprint
发送 「Go!」。这里已经没 有什么可以重构的了,只需要将变量重构为命名常量。
const finalWord = "Go!"
const countdownStart = 3
func Countdown(out io.Writer) {
for i := countdownStart; i > 0; i-- {
fmt.Fprintln(out, i)
}
fmt.Fprint(out, finalWord)
}
如果你现在运行程序,你应该可以获得想要的输出,但是向下计数的输出没有 1 秒的暂停。
Go 可以通过
time.Sleep
实现这个功能。尝试将其添加到我们的代码中。func Countdown(out io.Writer) {
for i := countdownStart; i > 0; i-- {
time.Sleep(1 * time.Second)
fmt.Fprintln(out, i)
}
time.Sleep(1 * time.Second)
fmt.Fprint(out, finalWord)
}
如果你运行程序,它会以我们期望的方式工作。
测试可以通过,软件按预期的工作。但是我们有一些问题:
- 我们的测试花费了 4 秒的时间运行
- 每一个关于软件开发的前沿思考性文章,都强调快速反馈循环的重要性。
- 缓慢的测试会破坏开发人员的生产力。
- 想象一下,如果需求变得更复杂,将会有更多的测试。对于每一次新的
Countdown
测试,我们是否会对被添加到测试运行中 4 秒钟感到满意呢?
- 我们还没有测试这个函数的一个重要属性。
我们有个
Sleep
ing 的注入,需要抽离出来然后我们才可以在测试中控制它。如果我们能够 mock
time.Sleep
,我们可以用 依赖注入 的方式去来代替「真正的」time.Sleep
,然后我们可以使用断言 监视调用。让我们将依赖关系定义为一个接口。这样我们就可以在
main
使用 真实的 Sleeper
,并且在我们的测试中使用 spy sleeper。通过使用接口,我们的 Countdown
函数忽略了这一点,并为调用者增加了一些灵活性。type Sleeper interface {
Sleep()
}
我做了一个设计的决定,我们的
Countdown
函数将不会负责 sleep
的时间长度。 这至少简化了我们的代码,也就是说,我们函数的使用者可以根据喜好配置休眠的时长。现在我们需要为我们使用的测试生成它的 mock。
type SpySleeper struct {
Calls int
}
func (s *SpySleeper) Sleep() {
s.Calls++
}
监视器(spies)是 一种 mock,它可以记录依赖关系是怎样被使用的。它们可以记录被传入来的参数,多少次等等。在我们的例子中,我们跟踪记录了
Sleep()
被调用了多少次,这样我们就可以在测试中检查它。更新测试以注入对我们监视器的依赖,并断言
sleep
被调用了 4 次。func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
spySleeper := &SpySleeper{}
Countdown(buffer, spySleeper)
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got '%s' want '%s'", got, want)
}
if spySleeper.Calls != 4 {
t.Errorf("not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
}
}
too many arguments in call to Countdown
have (*bytes.Buffer, Sleeper)
want (io.Writer)
我们需要更新
Countdow
来接受我们的 Sleeper
。func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
time.Sleep(1 * time.Second)
fmt.Fprintln(out, i)
}
time.Sleep(1 * time.Second)
fmt.Fprint(out, finalWord)
}
如果您再次尝试,你的
main
将不会出现相同编译错误的原因./main.go:26:11: not enough arguments in call to Countdown
have (*os.File)
want (io.Writer, Sleeper)
让我们创建一个 真正的 sleeper 来实现我们需要的接口
type ConfigurableSleeper struct {
duration time.Duration
}
func (o *ConfigurableSleeper) Sleep() {
time.Sleep(o.duration)
}
我决定做点额外的努力,让它成为我们真正的可配置的 sleeper。但你也可以在 1 秒内毫不费力地编写它。
我们可以在实际应用中使用它,就像这样:
func main() {
sleeper := &ConfigurableSleeper{1 * time.Second}
Countdown(os.Stdout, sleeper)
}
现在测试正在编译但是没有通过,因为我们仍然在调用
time.Sleep
而不是依赖注入。让我们解决这个问题。func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
sleeper.Sleep()
fmt.Fprintln(out, i)
}
sleeper.Sleep()
fmt.Fprint(out, finalWord)
}
测试应该可以该通过,并且不再需要 4 秒。
还有一个重要的特性,我们还没有测试过。
Countdown
应该在第一个打印之前 sleep,然后是直到最后一个前的每一个,例如:Sleep
Print N
Sleep
Print N-1
Sleep
etc
我们最新的修改只断言它已经
sleep
了 4 次,但是那些 sleeps
可能没按顺序发生。当你在写测试的时候,如果你没有信心,你的测试将给你足够的信心,尽管推翻它!(不过首先要确定你已经将你的更改提交给了源代码控制)。将代码更改为以下内容。
func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
sleeper.Sleep()
}
for i := countdownStart; i > 0; i-- {
fmt.Fprintln(out, i)
}
sleeper.Sleep()
fmt.Fprint(out, finalWord)
}
如果你运行测试,它们仍然应该通过,即使实现是错误的。
让我们再用一种新的测试来检查操作的顺序是否正确。
我们有两个不同的依赖项,我们希望将它们的所有操作记录到一个列表中。所以我们会为它们俩创建 同一个监视器。
type CountdownOperationsSpy struct {
Calls []string
}
func (s *CountdownOperationsSpy) Sleep() {
s.Calls = append(s.Calls, sleep)
}