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

书籍连接: book.douban.com/subject/259…数据库

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

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

第一部分 入门

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

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

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

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

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

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

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

第二部分 核心技术

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

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框架
相关文章
相关标签/搜索