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

786 阅读11分钟

书籍链接: book.douban.com/subject/259…

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

书中的代码是.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框架