Go 语言:通过TDD测试驱动开发学习 Mocking (模拟)的思想
正文:
现在需要你写一个程序,从 3 开始依次向下,当到 0 时打印 「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)
}
先写测试
通过花费一些时间让整个流程正确执行,我们就可以安全且轻松的迭代我们的解决方案。我们将不再需要停止并重新运行程序,要对它的工作充满信心因为所有的逻辑都被测试过了。
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)
}
如果你运行程序,它会以我们期望的方式工作。
Mocking
测试可以通过,软件按预期的工作。但是我们有一些问题:
- 我们的测试花费了 4 秒的时间运行
- 每一个关于软件开发的前沿思考性文章,都强调快速反馈循环的重要性。
- 缓慢的测试会破坏开发人员的生产力。
- 想象一下,如果需求变得更复杂,将会有更多的测试。对于每一次新的
Countdown测试,我们是否会对被添加到测试运行中 4 秒钟感到满意呢?
- 我们还没有测试这个函数的一个重要属性。
Sleeping 的注入,需要抽离出来然后我们才可以在测试中控制它。time.Sleep,我们可以用 依赖注入 的方式去来代替「真正的」time.Sleep,然后我们可以使用断言 监视调用先写测试
让我们将依赖关系定义为一个接口。这样我们就可以在 main 使用 真实的 Sleeper,并且在我们的测试中使用 spy sleeper。通过使用接口,我们的 Countdown 函数忽略了这一点,并为调用者增加了一些灵活性。
type Sleeper interface {
Sleep()
}
Countdown 函数将不会负责 sleep 的时间长度。 这至少简化了我们的代码,也就是说,我们函数的使用者可以根据喜好配置休眠的时长。type SpySleeper struct {
Calls int
}
func (s *SpySleeper) Sleep() {
s.Calls++
}
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)
}
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
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)
}
func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
s.Calls = append(s.Calls, write)
return
}
const write = "write"
const sleep = "sleep"
CountdownOperationsSpy 同时实现了 io.writer 和 Sleeper,把每一次调用记录到 slice。在这个测试中,我们只关心操作的顺序,所以只需要记录操作的代名词组成的列表就足够了。t.Run("sleep after every print", func(t *testing.T) {
spySleepPrinter := &CountdownOperationsSpy{}
Countdown(spySleepPrinter, spySleepPrinter)
want := []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
Sleeper 上有两个测试监视器,所以我们现在可以重构我们的测试,一个测试被打印的内容,另一个是确保我们在打印时间 sleep。最后我们可以删除第一个监视器,因为它已经不需要了。
func TestCountdown(t *testing.T) {
t.Run("prints 3 to Go!", func(t *testing.T) {
buffer := &bytes.Buffer{}
Countdown(buffer, &CountdownOperationsSpy{})
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got '%s' want '%s'", got, want)
}
})
t.Run("sleep after every print", func(t *testing.T) {
spySleepPrinter := &CountdownOperationsSpy{}
Countdown(spySleepPrinter, spySleepPrinter)
want := []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
}
我们现在有了自己的函数,并且它的两个重要的属性已经通过合理的测试。
难道 mocking 不是在作恶(evil)吗?
- 你正在进行的测试需要做太多的事情
- 把模块分开就会减少测试内容
- 它的依赖关系太细致
- 考虑如何将这些依赖项合并到一个有意义的模块中
- 你的测试过于关注实现细节
- 最好测试预期的行为,而不是功能的实现
但是模拟和测试仍然让我举步维艰!
- 你想做一些重构
- 为了做到这一点,你最终会改变很多测试
- 你对测试驱动开发提出质疑,并在媒体上发表一篇文章,标题为「Mocking 是有害的」
这通常是您测试太多 实现细节 的标志。尽力克服这个问题,所以你的测试将测试 有用的行为,除非这个实现对于系统运行非常重要。
有时候很难知道到底要测试到 什么级别,但是这里有一些我试图遵循的思维过程和规则。
- 重构的定义是代码更改,但行为保持不变。 如果您已经决定在理论上进行一些重构,那么你应该能够在没有任何测试更改的情况下进行提交。所以,在写测试的时候问问自己。
- 我是在测试我想要的行为还是实现细节?
- 如果我要重构这段代码,我需要对测试做很多修改吗?
- 虽然 Go 允许你测试私有函数,但我将避免它作为私有函数与实现有关。
- 我觉得如果一个测试 超过 3 个模拟,那么它就是警告 —— 是时候重新考虑设计。
- 小心使用监视器。监视器让你看到你正在编写的算法的内部细节,这是非常有用的,但是这意味着你的测试代码和实现之间的耦合更紧密。如果你要监视这些细节,请确保你真的在乎这些细节。
总结
- 当面对不太简单的例子,把问题分解成「简单的模块」。试着让你的工作软件尽快得到测试的支持,以避免掉进兔子洞(rabbit holes,意指未知的领域)和采取「最终测试(Big bang)」的方法。
- 一旦你有一些正在工作的软件,小步迭代
Go 语言:通过TDD测试驱动开发学习 Mocking (模拟)的思想的更多相关文章
- TDD(测试驱动开发)学习一:初识TDD
首先说一下名词解释,TDD,英文名称Test-Driven Development,中文名称测试驱动开发,简单的断下句“测试/驱动/开发”,简单的理解一下,就是测试驱动着开发,大白话就是说用一边测试一 ...
- TDD(测试驱动开发)学习二:创建第一个TDD程序
本节我们将学习一些测试驱动开发环境的搭建,测试驱动开发概念和流程.所涉及的内容全部会以截图的形式贴出来,如果你也感兴趣,可以一步一步的跟着来做,如果你有任何问题,可以进行留言,我也会很高兴的为你答疑. ...
- 测试驱动开发学习笔记(UTDD)
title: 测试驱动开发学习笔记(UTDD) date: 2020-08-01 23:59:17 tags: [2020, 学习一门技能, TDD, DevOps] What TDD(Test-Dr ...
- TDD(测试驱动开发)培训录
2014年我一直从事在敏捷实践咨询项目,这也是我颇有收获的一年,特别是咨询项目的每一点改变,不管是代码质量的提高,还是自组织团队的建设,都能让我们感到欣慰.涉及人的问题都是复杂问题,改变人,改变一个组 ...
- TDD(测试驱动开发)培训录(转)
本文转载自:http://www.cnblogs.com/whitewolf/p/4205761.html 最近也在了解TDD,发现这篇文章不错,特此转载一下. TDD(测试驱动开发)培训录 2015 ...
- TDD(测试驱动开发)
TDD(测试驱动开发)培训录 2014年我一直从事在敏捷实践咨询项目,这也是我颇有收获的一年,特别是咨询项目的每一点改变,不管是代码质量的提高,还是自组织团队的建设,都能让我们感到欣慰.涉及人的问题都 ...
- (译)TDD(测试驱动开发)的5个步骤
原文:5 steps of test-driven development https://developer.ibm.com/articles/5-steps-of-test-driven-deve ...
- 基于SOA架构的TDD测试驱动开发模式
以需求用例为基,Case&Coding两条线并行,服务(M)&消费(VC)分离,单元.接口.功能.集成四层质量管理,自动化集成.测试.交付全程支持. 3个大阶段(需求分析阶段.研发准备 ...
- TDD(测试驱动开发)的推广方法论
- 测试驱动开发(TDD)的思考
极限编程 敏捷开发是一种思想,极限编程也是一种思想,它与敏捷开发某些目标是一致的.只是实现方式不同.测试驱动开发是极限编程的一部分. 1.极限编程这个思路的来源 Kent Beck先生最早在其极限编程 ...
随机推荐
- plsql链接oracle
安装两个oracle文件夹在一个database中,安装plsql 要先配置两个都要修改,不然会找不到服务器 管理员运行 监听服务/监听位置和数据库服务都要修改 ass文件---监听程序配置 和本场 ...
- Vue3 + Vue Router 4.x 添加过渡动效报错
1. 报错信息 2. 报错原因 检查页面代码发现动效出错页面为多根节点,修改后动效正常 <template> <div> <div>xxx</div> ...
- 将Vue项目部署到Nginx中,出现的400,405,200响应空等问题处理
最近用Vue3写了个项目,然后对接后台接口. 在本地vue配置文件中,配置了反向代理.成功请求了后端接口. 自测没有问题. 打包vue,发布到nginx中.运行nginx,成功显示了页面. 当点击页面 ...
- 【Android异常】关于静态注册BroadcastReceiver接收不到系统广播的问题
如果你静态注册的广播无法接收到消息,请先检查下:你的安卓版本是不是8.0+ 前言Google官方声明:Beginning with Android 8.0 (API level 26), the sy ...
- 双指针_Leetcode刷题_13/100
算法解释 双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务.也可以延伸到多个数组的多个指针. 若两个指针指向同一个数组,遍历的方向相同且不会相交,则也称为滑动窗口: 若两个指针指向同一 ...
- C++运算符重载引用传参与返回引用的小小心得
1 #include <bits/stdc++.h> 2 3 using namespace std; 4 5 //平面向量类,提供完成向量运算和比较的API 6 //除递增运算符和左移运 ...
- 【jupyter notebook】配置基于ecs云平台的jupyter notebook远程使用
学校提供了ecs云服务器进行高性能运算 分配到了云主机后想在上面搭好jupyter notebook服务器方便远程使用,在此记录整个流程和遇到的问题 本文档适用于常规的jupyter notebook ...
- IO在指定目录建文件
path= "d:\\Project\\log\\log.txt"; File file = new File(path); //创建文件目录 if(!file.getParent ...
- 多线程post
async Task<long> post() { var stopwatch = new Stopwatch(); stopwatch.Start(); var client = new ...
- LoadRunner压力测试(web)
1.打开Virtual User Generator->新建脚本->选择创建新脚本类型,web-HTTP,HTML->创建 2.录制脚本 3.停止脚本录制 4.创建controlle ...