LeeYzero的博客

业精于勤,行成于思

0%

单元测试的实践与思考

从个人经历过的多个团队中,发现有一个共性是:极少有团队注重单元测试。甚至当我想在团队中倡导单元测试时,有个研发经理跟我表达单元测试没有任何意义的结论。我不知道这是个人的不幸呢,还是整个中国互联网公司的现状如此?

本文算是给单元测试”正名”,介绍单元测试的意义以及编写可测试代码的一些原则和思考。

什么是单元测试

单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行检验。单元测试几乎都是用单元测试框架编写的。单元测试容易编写,能够快速运行。单元测试可靠、可读、并且可维护。只要产品代码不发生变化,单元测试的结果是稳定的。

——The Art of Unit Testing

工作单元是指从调用系统的一个公共方法到产生一个测试可见的最终结果,其间这个系统发生的行为总称为一个工作单元。一个单元可以小到只包含一个方法,也可以大到包括实现某个功能的多个类和函数。

为什么需要单元测试

单元测试对提高产品质量非常重要,也是开发人员提高代码质量,加深理解类和功能需求的最佳手段之一。

测试金字塔

1

测试金字塔(The Test Pyramid)是一条很好的经验法则,表明从单元测试(Unit Tests)、集成测试(Integration Tests)、UI测试(UI Tests)以及端到端测试(End-to-End Tests)成金字塔结构。从金字塔底部至顶部,主要关注三点:

  • 层次越高,集成度越高
  • 层次越高,测试速度越慢
  • 层次越高,测试数量越少

单元测试处于测试金字塔最底层,是集成测试、UI测试、端到端测试的基础。它的集成度最低,可以完成独立于其它组件单独运行;它的测试速度非常快,代码变更后可以随时、快速测试代码变更影响;它的测试数量最多,可以100%覆盖代码逻辑。

测试金字塔给我们一个直观的展示,表明单元测试在整个测试体系下的重要性,它的具体优点如下。

单元测试的优点

单元测试有利于更早发现软件缺陷,降低修复成本

据统计,大约有80%的错误是在软件设计阶段引入的,并且修复一个软件错误所需要的费用随着软件生命周期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。

单元测试有利于开发者增加变更代码的信息

开发者如果对代码变更带来不确定的影响,会迫使开发者尽量少的引入变更,即便这个变更是合理的。单元测试会让开发者对自己的变更更自信,也更有利于持续对代码进行重构。

单元测试有利于代码重用

单元测试的目标是对相互独立的单元进行验证,为了保证可测试性,需要将单元拆分的尽量小,减小单元的复杂度,这样有利于测试,也利于代码复用。

单元测试能提供一份良好的接口使用文档

代码和接口文档同步广为开发人员诟病,使用别人的接口人档时,经常吐槽文档写的不好,接口文档没有实时更新。当自己变更代码后,却时常忘记同步接口文档。

单元测试天然和代码逻辑实时同步,为了让测试通过,代码逻辑变更要求测试同步变更(注意单元测试不等于TDD)。可以认为单元测试是系统内部的一个虚拟用户,它站在开发者用户角度使用系统内部API,以更精确、更细粒度的方式测试系统行为。

单元测试能够提高软件开发速度

在人月神话中,有一条经验法则:在整个软件开发周期中,编码时间只占1/6。虽然写单元测试要花费额外的成本(基本增加一倍),但它缩减集成测试、端到端测试的时间,定位BUG也会更迅速,最终的效果是整体交付时间减少,软件缺陷数减少。

单元测试的缺点

了解了单元测试的优点之后,还有必要了解它的局限性。这有助于我们对单元测试有一个更全面的认识。

  • 单元测试不能测试出软件的所有问题
  • 单元测试在测试金字塔的最底层,不能测试和外部系统交互的边界
  • 单元测试本身是模拟系统行为,可能并不能反映真实系统行为
  • 编码阶段需要花费更多时间编写测试代码

为什么你的代码不容易测试

当你开始着手编写单元测试时,你有没有发现你很难对你的代码编写单元测试。你有没有思考过为什么会这样?在看后续的内容之前,先尝试思考一下下面的问题:

  • 为什么无状态、确定输入和输出的函数容易测试?
  • 为什么依赖具体资源(文件系统、数据库、网络、线程、时间等)的类不容易测试?
  • 为什么依赖于全局状态的类不容易测试?

软件设计原则

计算机软件经过几十年的发展,造就了如今的信息化时代。无数软件设计大师在软件开发过程中总结历史经验教训,提炼出这些原则,让我们少走了很多弯路。

需要注意的是原则是死的,人是活的。这些原则更多的是多通用场景考虑,场景不同,解决问题的思路也可能不一样,在实际应用时不要被原则束缚了。本文只是简单介绍一下这些原则,有助于我们理解如何编写可测试的代码。

基本原则

Don’t Repeat Yourself(DRY)

重复代码使得代码维护变得更困难。逻辑变更时,都可能产生BUG或者让代码变更更困难。这可以通过DRY原则来达到代码重用。

DRY 原则指出:系统中的每一条知识都必须有一个单一的(single)、明确的(unambiguous)、权威的(authoritative)的表示。

Keep It Simple Stupid(KISS)

KISS 原则指出:大多数系统如果保持简单而不是复杂化时,则效果最好。因此,简单性应该是设计的关键目标,应该避免不必要的复杂性。

You Aren’t Gonna Need It(YAGNI)

YAGNI 是一种极限编程 (Extreme Programming,XP) 实践,它指出:总在你真正需要的时候才去实现它们,而不是在你预见到你需要它们的时候去实现。

更多基本原则参考:Software Design Principles (Basics) | DRY, YAGNI, KISS, etc

SOLID原则

SOLID原则是5个软件设计原则,最早由 Robert C. Martin (Uncle Bob) 在他的论文 Design Principles and
Design Patterns
中提出。SOLID原则旨在让软件设计更具可理解性、维护性和灵活性。其中SOLID是以下5个原则的缩写:

  • Single Responsibility Principle(SRP),单一职责原则
  • Open Closed Principle(OCP),开放封闭原则
  • Liskov Substitution Principle(LSP),Liskov替换原则
  • Interface Segregation Principle(ISP),接口隔离原则
  • Dependency Inversion Principle(DIP),依赖倒置原则

单一职责原则

对于一个软件实体(类、模块、函数)而言,应该只有一个引起它变化的原因。换句话说,每个类只承担一个职责。

单一职责原则是最容易理解的原则,但也是最难应用的原则。因为对于职责界定,需要大量的经验和专业背景知识。

你的类责任越多,你就越需要经常变更它。由于它们不会相互独立,因此更改一项职责可能会影响另一项职责。

开放封闭原则

软件实体(类、模块、函数)等应该对扩展开放,对修改封闭。

这也是我们常说的向前兼容,向后扩展。开放封闭原则的关键是抽象(Abstraction)。将业务需求抽象为接口,接口不应该根据新需要而改变,接口是可扩展的,新的需求可以通过实现类来扩展。

Liskov替换原则

使用指向基类的指针或引用的函数必须能够在透明的情况下使用派生类的对象。

Liskov 替换原则指出子类应该可以替换它们的基类。无论在何处使用派生类,派生类都应该可以替代它们的基类。

接口隔离原则

许多特定于客户端的接口优于一个通用接口。

不应强迫客户依赖他们不使用的接口。拥有更小的接口比拥有更少、更胖的接口要好。接口应该更多地依赖于调用它的代码,而不是实现它的代码。

依赖倒置原则

依赖于抽象,而不是具体的实现。

依赖倒置原则指出“依赖于抽象,而不是具体实现”。这意味着对于一个类,如果依赖于其它类,应该依赖于其它类的抽象(接口),而不是具体的实现。

更多关于SOLID原则请参考:SOLID Design Principles | Software Design Principles | Uncle Bob

Demeter原则

Demeter原则Law of Demeter,LoD),也被称作最小知识原则(Principle of Least Knowledge),是一种松耦合软件设计的指导原则。可以形式化的总结为:

  • 每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元;
  • 每个单元只能和它的朋友交谈:不能和陌生单元交谈;
  • 只和自己直接的朋友交谈。

编写可测试代码的套路

可测试代码的本质

编写可测试代码的关键是预留测试接口。单元测试可以看作是系统的内部用户。对被测试的单元,单元测试构造特定输入以校验相应的输出,测试接口用于单元测试构造输入。

单元测试的艺术在于如何优雅地编写测试接口。

回到上文的问题,如果一个函数无状态(不依赖于全局状态),有确定性的输入和输出,它本身就是最好的测试接口,只需要构造确定的输出,就能得到确定的输出;对于一个类,如果类本身有状态,而这个类没有提供测试接口修改这些状态,将很难覆盖类内部逻辑;如果这个类依赖于特定资源,相当于依赖于具体实现,这些实现通常是不确定或难以构造的,同样会导致代码难以测试。

如果你明白预留测试接口的真正含义,本文已经其它东西可以告诉了。如果把软件设计原则看作是,那下面介绍的便是,也就是武侠中的招式。这些招式遵循软件设计原则,是对通用场景下的模式总结。对于编写可测试代码来说,应用最多的原则按优先级排序如下:

  • 依赖倒置原则
  • 单一职责原则
  • Demeter原则

编写可测试代码指南

以下指南来自于 Miško Hevery 的文章 Guide: Writing Testable Code,本文以自己的理解做简单解释,具体细节和示例请参考原文。

深入理解构造函数

还记得上文提到的测试接口吗?构造函数是设定类的初始状态,如果公有接口没有足够的能力修改类的内部状态,测试时就很难通过变更类的内部状态以达到测试目的。

警告信号

  • 构造函数中使用new关键字
  • 构造函数中调用静态方法
  • 构造函数中赋值非类的属性
  • 构造函数中没有初始化所有状态(如,使用Initialize)
  • 构造函数中包括控制逻辑
  • 构造函数中包含复杂的构造对象图

解决办法

  • 依赖倒置原则
  • Liskov原则
  • Demeter原则
  • 工厂(Factory)、构造器(Builder)

深入理解对象

一个业务逻辑可能需要多个类进行通信完成。类与类之间的通信也可以称为数据流向,或依赖关系。Demeter原则让类之间的依赖程序降至最低,依赖注入可以为测试预留接入。

警告信号

  • 传入的对象没有直接使用,而是间接使用了对象的对象
  • 违反Demeter原则
  • 警惕根对象:context、environment、container、manager等

解决办法

  • Demeter原则
  • 依赖注入(Dependency Injection)
  • 组合优于继承
  • 区分值对象和服务对象

全局状态和单例

单例本质上也是一种全局状态。全局状态如果被类所依赖,那这个全局状态将在所有依赖对象之间传递,相互影响。由于对象之间间接影响,一个类的单元测试改变状态后,无形中影响到另一个类的状态,造成另一个的单元测试不通过,在实际系统中,这种情况很容易产生BUG,而且难以调试。

大家可能会对全局状态有个误解,认为所有的全局状态都是不合理的,如果全局状态或单例仅作为根对象进行初始化,且全局状态是只读的,各个类应用依赖注入和Demeter原则,是能够不规避直接依赖于全局状态的。

警告信号

  • 类中添加静态方法或静态类
  • 类中添加静态Initialize方法
  • 单例类被其它对象直接依赖

解决办法

  • 依赖倒置原则
  • 依赖注入(Dependency Injection)
  • Demeter原则

类承担太多职责

对于“胖”的类,单一职责原则是利器。对于一个类而言,单一职责本质上是控制类的复杂度。至于职责如何划分,依赖于具体业务形态。

警告信号

  • 类或方法名中包含and
  • 类对团队的新成员难以理解
  • 类的个另属性仅仅被某个方法使用
  • 类包含的静态方法仅作用于输入参数,并且输入参数和类相关性不大

解决办法

  • 单一职责原则
  • 接口隔离原则
  • Demeter原则

好的单元测试的特性

  • 它应该是自动化的,可重复执行的
  • 它应该很容易实现
  • 它应该第二天还有意义
  • 任何人都可以一键能运行它
  • 它应该运行速度很快
  • 它的结果应该是稳定的
  • 它应该能完全控制被测试的单元
  • 如果它失败了,应该很容易定位问题所在

以上特性可以归结两点:

  • 好的单元测试代码是遵循基本软件设计原则的
  • 利用单元测试工具提高生产效率

重新思考单元测试

对个人来讲,单元测试是一项基本技能,是从软件开发从业人员职业素养的体现。学习单元测试的基本技能能够让代码质量得到比较大的提升,同时能够尽早规避一些BUG,减少了很多后期排查问题的时间。

对团队而言,单元测试更多的体现一种文化传承和专业程度。现在很多团队都在倡导所谓的快速迭代、敏捷开发,殊不知很多团队只是借敏捷的概念在做软件开发,只是在做软件开发,而不是软件工程。

对整个中国互联网来看,经历了30年的快速发展,不可否认取得非常大的成就。但从头部互联网企业来看,还是主要集中在C端消费互联网。在专业领域上,我们太缺乏领域技术专家去突破瓶颈。而这也从侧面反映虽然中国互联网的软件开发从业人员非常多,但整体素质偏低。996、35岁被优化、学而优则仕,这是中国互联网的现状。这跟单元测试有什么关系吗?我只想说大部分团队其实没有走在正确的软件工程道路上,特别是做业务开发的团队。

参考资料