单元测试的艺术-读书笔记

这本书教你为什么要关注可测试性, 如何编写可测试的代码, 以及如何推动测试落地.

书中的代码是.NET的, 不太习惯, 后文中我改成用Golang进行举例.

第一部分 入门

第一部分是入门章节, 告诉我们什么是单元测试, 为什么要用单元测试, 如何写好单元测试.

什么是单元测试? 单元测试是一段自动化的代码, 这段代码调用被测试的工作单元, 之后对这个单元的单个最终结果的某些假设进行检验. 这里需要注意一个概念: 单元. 从调用系统的一个公共方法到产生一个测试可见的最终结果, 其间这个系统发生的行为总称为一个工作单元.

一个相关的概念是集成测试. 单元测试和集成测试的最主要区别在于: 是否使用了真实依赖物, 如数据库, 网络连接等等.

什么是好的单元测试? 优秀单元测试应该有如下特性:

  • 自动化, 可重复执行
  • 运行结果是稳定的
  • 容易编写
  • 运行快速
  • 能够完全控制被测试的单元

在开发过程中应该何时编写单元测试? 这个见仁见智, 可以采用TDD先写测试后写功能, 也可以写完功能再补测试.

单元测试的目的在于使得代码可维护. 如果你的单元测试没有促进这一目标, 就要反思是不是真的写好了单元测试.

第二部分 核心技术

核心技术章节主要介绍了如何使系统与外部依赖项隔离, 从而进行去依赖的单元测试.

fake & stub

通常我们开发的系统都会有各种外部依赖项. 外部依赖项是系统中的一个对象, 被测试代码与这个对象发生交互, 但你不能控制这个对象. 常见外部依赖项包括文件系统, 线程, 内存以及时间等. 注意: 一旦你的系统中引入的真实的外部依赖项, 那么你进行的就是集成测试, 而非单元测试.

显然, 如果没法控制外部依赖项的行为, 就无法保证单元测试运行结果的稳定性. 那么如何使外部依赖项可控? 答案是使用伪对象 (fake) 替代真实的外部依赖对象.

那么接下来的问题是, 如何用伪对象替代外部依赖? 只需要找到被测试单元使用的外部接口, 然后将接口的底层实现替换成你能控制的代码. 如果这个接口与被测试单元直接相连, 就添加一个间接层, 隐藏这个接口. 来看下面这个例子:

// 判断文件是否存在
func IsFileExist(fileName string) bool {
  _, err := os.Stat(fileName)
  if err == nil {
    return true
  }
  if os.IsNotExist(err) {
    return false
  }
  return false
}

这段代码引入了文件系统依赖, 需要用一个伪对象替代真实文件系统. 然而, 文件系统有关的代码已经写死在函数里了, 这就需要引入一个中间层, 抽象出文件系统的操作. 这里我们声明一个IFileManager接口:

type IFileManager interface {
  IsFileExist(string) bool
}

提供一个真实的文件系统实现和一个伪对象实现:

type PureFileManager struct{}

func (t *PureFileManager) IsFileExist(fileName string) bool {
  _, err := os.Stat(fileName)
  if err == nil {
    return true
  }
  if os.IsNotExist(err) {
    return false
  }
  return false
}

type FakeFileManager struct{}

func (t *FakeFileManager) IsFileExist(fileName string) bool {
  return true // 行为完全由我们控制, 这里无脑返回true
}

改写之前的IsFileExist()函数:

func IsFileExist(fileName string) bool {
  var mgr IFileNameManager = new(PureFileNameManager)
  return mgr.IsFileExist(fileName)
}

这样虽然添加了一个中间层, 但是还是与外部依赖绑定了. 解决办法是: 使用依赖注入, 在被测试单元中注入一个伪实现. 依赖注入有两种方法: 构造函数注入和属性注入. 如何选择? 如果依赖项是必须的, 就用构造函数注入, 否则尽量使用属性注入. 下面为我们最终改造后的代码, 通过这样的改造, IsFileExist()函数就与文件系统的强依赖解耦了, 可以进行单元测试了.

var mgr IFileNameManager

func SetPureFileNameManager() {
  mgr = new(PureFileNameManager)
}

func SetFakeFileNameManager() {
  mgr = new(FakeFileManager)
}

func IsFileExist(fileName string) bool {
  return mgr.IsFileExist(fileName)
}

mock

一个工作单元可能有三种最终结果: 返回值, 改变系统状态, 调用第三方对象. 对单元测试来讲, 前两种结果与第三种有一个显著区别: 返回值和改变系统状态都是在当前系统中可观测到的, 可以直接对当前系统进行断言以验证测试结果正确性. 而调用第三方对象时, 当前系统的状态有可能未发生任何改变, 验证测试结果正确性需要对第三方对象进行断言. 这就引出了另外两个概念: 存根 (stub)和模拟(mock).

fake对象既可以作stub, 也可以作mock, 区别在于: 测试结果是否依赖对fake对象的断言 (也就是书中说的: stub不会导致测试失败, 而mock可以). 如果是, 那fake对象就是mock, 否则就是stub. 换言之, mock关注工作单元对外部依赖影响, stub关注外部依赖返回给工作单元的结果.

来看下面这段代码. 这一段实现了这样一个功能: 如果是星期六, 天气下雨, 就订一份外卖:

var kfc KFC // KFC餐厅
var address string // 我的收货地址

type KFC struct {}
func (t *KFC) OrderLunch(address string) {
  fmt.Printf("Lunch is send out to %s, please wait!\n", address)
}

func DailyTask(date, weather string) {
  addr := GetMyAddress()
  if isSatuday(date) && isRainy(weather) {
    kfc.OrderLunch(addr) // 给KFC发送一个订餐事件
  }
}

我们如何确定这个DailyTask()是否真的会在星期六的雨天发送这样一个订餐事件? OrderLunch()方法并没有返回值, 也没有改变被测系统的状态, 需要检查KFC内部的状态. 因此, 我们引入一个MockKFC对象, 调用该对象的OrderLunch()可以记录下传入的address参数.

var kfc IKFC // 把KFC变成一个接口, 便于注入mock对象

type IKFC interface {
  OrderLunch(address) // 送餐
}

type MockKFC struct{
  TestAddr string
}
func (t *MockKFC) OrderLunch(address string) {
  t.TestAddr = address
  fmt.Printf("Lunch is send out to %s, please wait!\n", address)
}

这样改造之后, 测试代码就很好写了:

func TestDailyTask(t *testing.T) {
  kfc = new(MockKFC)
  address = "zju"
  DailyTask("Saturday", "rainy")
  if kfc.TestAddr != address {
    t.Error("address not equal")
  }
}

mock框架

一般来说, 我们不会手动管理mock对象, 而是采用mock框架帮助我们完成这些事情. 书中第5,6章介绍了mock框架方方面面, 包括工作原理, 分类, 模式等等. 没太仔细看.

第三部分 测试代码

主要介绍了测试代码的组织方式 (第7章) 以及编写测试的最佳实践 (第8章).

如何组织测试代码

首先需要明白的是, 对于一个应用程序而言, 单元测试和产品源代码同等重要. 测试代码同样需要维护.

  • 使你的测试自动化, 并且与自动化构建建立关联.
  • 根据测试类型组织测试代码, 将单元测试和集成测试放到不同的目录下.
  • 确保测试代码是源代码管理的一部分, 应将测试代码放入代码仓库中,并保证测试代码版本和所测试的产品代码版本相对应.
  • 建立测试代码和被测代码的映射关系.
  • 注入横切关注点. 例如系统时间, 如果改写为注入, 无疑会使代码复杂化. 作者给出的解决方案是封装一个自定义的系统时间类, 该类可以对系统时间进行自定义设置. 这样就很容易对
  • 持续对测试代码进行重构, 提高测试代码的可维护性.

如何写好测试代码

优秀的测试应该同时具有如下三个属性: 可靠性, 可维护性, 可读性.

编写可靠的测试

关于如何编写可靠的测试, 作者给出了几点建议:

  • 跟随产品需求的变化, 删除或修改原有测试代码
  • 避免测试中的控制逻辑
  • 每个测试只测试一个关注点
  • 把单元测试和集成测试分开
  • 用代码审查确保测试覆盖率是有效的

编写可维护的测试

  • 测试私有方法时, 思考其必要性
  • 去除重复代码
  • 以可维护的方式使用setup方法 (重要)
  • 实施测试隔离, 一个测试不应依赖于其他测试, 测试不应该依赖顺序
  • 避免对不同关注点多次断言, 防止某一断言失败导致其后的断言无法执行
  • 对象比较时, 不要对对象中的每个属性进行断言, 而应该对对象整体进行断言
  • 避免过度指定 (只检查最终行为的正确性, 不要对被测单元的内部行为进行假设)

编写可读的测试

单元测试命名非常重要. 一个测试名包含三个部分: 被测试方法名, 测试场景, 预期行为. 例如: Sum_ByDefault_ReturnsZero()

注意单元测试中的变量命名规范, 不要出现magic number, 应该在变量名中反映出变量的含义.

给出有意义的断言信息, 能够清晰地反映测试结果. 同时在代码上要将断言和操作分离, 不要把断言和操作写到一行里面.

不要滥用setup和teardown. 初始化模拟对象, 设置预期值这些操作应该放到测试方法中, 而不应该放到setup中. teardown一般用于集成测试, 在单元测试中, 只会在重置一个静态变量或单例的状态时才会使用.

第四部分 测试流程

在组织中引入单元测试

这一章跳出了单元测试的技术细节, 转而从管理的角度探讨了如何推动单元测试在团队中落地.

如何在组织中引入单元测试? 作者给出的建议是: 小团队, 低风险项目, 领导者愿意接受变革. 需要注意, 一定要在确保你了解单元测试的基础上, 再推动单元测试, 万不可仓促实施单元测试. 此外, 还需要一定的政策上的支持.

作者指出, 要开始单元测试, 至少需要30%的工作时间. 然而, 引入单元测试并不一定会导致整体流程时间增加. 进行单元测试更容易在开发期修复bug, 从而减少集成测试的时间, 项目的交付期有可能提前.

单元测试是否会抢了QA饭碗? 不会的, 单元测试的存在会使QA更专注于寻找实际应用中的逻辑缺陷, 让QA专注于更大的问题. 有些公司QA工程师也写代码, 开发者和QA工程师都可以编写单元测试.

作者有一句话写得非常好: 你需要使用单元测试, 确保人们知道代码的功能是否受到破坏.

编码是代码生命周期的第一步. 在生命周期的大部分阶段, 代码都处于维护模式. "大部分的缺陷并不是来自代码自身, 而是由人们之间的误解, 不断变化的需求以及缺少应用领域知识造成的."

遗留代码

对一个遗留项目, 如何从0到1开始单元测试? 首先你需要列出项目组件的测试优先级. 可以通过逻辑复杂度, 依赖数, 重要程度判断其优先级. 一般来说, 逻辑驱动的容易测试, 依赖驱动的难以测试 (需要mock).

在确定了优先级之后, 需要选择测试策略, 先易后难, 先难后易, 各有优劣.

在重构代码前, 先进行集成测试, 确保重构时不会破坏原有功能.

设计与可测试性

什么是可测试的设计? 就是代码架构便于进行测试. 而测试的关键在于"接缝". 对静态语言来说, 需要主动采用允许替换的设计 (即提供接口), 代码才能获得可测试性. 而对于动态语言, 可测试性设计就显得不那么有意义.

可测试的设计与SOLID原则相关, 一般来说满足SOLID原则的设计都是可测试的设计, 而反之不成立, 因此: 可设计性并不是优秀设计的目标, 而是优秀设计的副产品.

可测试设计会增加工作量, 编写更多的代码. 可以首先使用简单设计, 在需要时再进行重构.

延伸阅读

书中提到了一些其他的技术书籍, 个人觉得有些书籍还不错, 列举如下:

  • Clean Code, 优秀代码风格
  • Dependency Injection in .NET, 教你写IoC框架
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容