重构手段-单元测试¶
备注
最可落地执行、最有效的保证重构不出错的手段。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变
定义¶
单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。
单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。
集成测试的测试对象是整个系统或者某个功能模块,
比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。
而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。
备注
涉及到数据库的确实比较难写单元测试,而且如果重度依赖数据库,业务逻辑又不复杂,单元测试确实没有太大意义。这个时候,集成测试可能更有意义些。
作用¶
备注
单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)。
单元测试能有效地帮你发现代码中的 bug:
节省了我很多 fix 低级 bug 的时间,能够有时间去做其他更有意义的事情 坚持写单元测试是保证我的代码质量的一个“杀手锏”,也是帮助我拉开与其他人差距的一个“小秘密”。
写单元测试能帮你发现代码设计上的问题:
代码的可测试性是评判代码质量的一个重要标准。 对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力, 那就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等
单元测试是对集成测试的有力补充:
程序运行的 bug 往往出现在一些边界条件、异常情况下, 而这种异常大部分情况都比较难在测试环境中模拟。 而单元测试可以利用下一节课中讲到的 mock 的方式, 控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。 对于一些复杂系统来说,集成测试也无法覆盖得很全面。 复杂系统往往有很多模块。 每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟, 无数的测试用例需要设计,再强大的测试团队也无法穷举完备。 尽管单元测试无法完全替代集成测试, 但如果我们能保证每个类、每个函数都能按照我们的预期来执行, 底层 bug 少了,那组装起来的整个系统,出问题的概率也就相应减少了。
写单元测试的过程本身就是代码重构的过程:
写单元测试实际上就是落地执行持续重构的一个有效途径。 设计和实现代码的时候,我们很难把所有的问题都想清楚。 而编写单元测试就相当于对代码的一次自我 Code Review, 在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试) 以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。
阅读单元测试能帮助你快速熟悉代码:
阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。 在没有文档和注释的情况下,单元测试就起了替代性作用。 单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。 借助单元测试,不需要深入的阅读代码,便能知道代码实现了什么功能
单元测试是 TDD 可落地执行的改进方案:
单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试, 最后根据单元测试反馈出来问题,再回过头去重构代码。
如何编写单元测试¶
编写单元测试尽管繁琐,但并不是太耗时:
单元测试的代码量可能是被测代码本身的 1~2 倍,写的过程很繁琐,但并不是很耗时。 毕竟我们不需要考虑太多代码设计上的问题,测试代码实现起来也比较简单。
我们可以稍微放低对单元测试代码质量的要求:
单元测试不在线上运行,所以相比正式代码可以稍稍降低要求
覆盖率作为衡量单元测试质量的唯一标准是不合理的:
单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准 不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。 实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case 过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率,写很多没有必要的测试代码
单元测试不要依赖被测代码的具体实现逻辑:
它只关心被测函数实现了什么功能。 否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改, 那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷。
单元测试框架无法测试,多半是因为代码的可测试性不好:
写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。 在公司内部,起码团队内部需要统一单元测试框架。 如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。 这个时候,我们要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架。
为何难落地执行¶
写单元测试确实是一件考验耐心的活儿:
一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。
很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。
有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。
但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现『破窗效应』,慢慢的,大家就都不写了
国内研发比较偏向“快、糙、猛”:
程序员这一行业本该是智力密集型的,但现在很多公司把它搞成劳动密集型的,
写好代码直接提交,然后丢给黑盒测试狠命去测,
测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复
关键问题还是团队没有建立对单元测试正确的认识:
觉得可有可无,单靠督促很难执行得很好。
影响可测试性的 Anti-Patterns¶
未决行为:
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的
全局变量:
全局变量是一种面向过程的编程风格,有种种弊端。滥用全局变量也让编写单元测试变得困难
静态方法:
静态方法跟全局变量一样,也是一种面向过程的编程思维。 在代码中调用静态方法,有时候会导致代码不易测试。 主要原因是静态方法也很难 mock
复杂继承:
相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。 实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。 利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平, 在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。
高耦合代码:
如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合, 那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。 不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。