深圳幻海软件技术有限公司 欢迎您!

聊聊为什么需要单元测试?

2023-02-28

没有单元测试时的验证在学习编程和业务开发的工程中,我们有一段时间总是在讨论:单元测试是否有用?而进行这种讨论的主要原因是,我们似乎在不使用单元测试的时候,项目也可以跑得很好。小到毕业设计时的内容,大到一个十几人大小的团队。我们设计项目、分析需求,然后根据设计的结果进行代码的编写,然后进行接口或者业务

没有单元测试时的验证

在学习编程和业务开发的工程中,我们有一段时间总是在讨论:单元测试是否有用?而进行这种讨论的主要原因是,我们似乎在不使用单元测试的时候,项目也可以跑得很好。小到毕业设计时的内容,大到一个十几人大小的团队。我们设计项目、分析需求,然后根据设计的结果进行代码的编写,然后进行接口或者业务执行上面的测试,让我们知道所编写的代码已经可以完美的完成计划内容后,会请测试同学帮我们进行代码测试,以保证他们确实完成了计划中的内容。最终,代码上线,可喜可贺。

看起来没什么不好的,直到最终的问题发生。

我们当然会在开发的时候进行项目功能的测试,常用手段诸如用main对指定的代码块验证,或者使用postman对我们设计的接口进行测试验证。或许中间还存在了一些数据库的修改,比如模拟下单数据,或者模拟用户注册。

这些方法是一定程度上可以完成当期内的功能需求的,否则也不会有那么多的“单元测试真的有用吗”的这种声音。那么问题是什么呢?

问题是你无法永远保证“当期的业务测试”就是能覆盖你本期提供的功能点,以及即便是测试同学保存有以往所有测试用例的自动化测试内容,也无法真正的保证你的系统是完好的,因为业务功能和软件功能中间是有隔阂的。

尽管是搞笑图,但是精准的命中了我要说的内容:

针对用例设计的功能测试,无法保证你的“系统”正常。

测试驱动开发

我们一般在项目开发中进行的功能测试,是可以保证当期中业务流程。但是即便功能测试的过程包含了所有的过往功能,也只能保证业务流程是正确的,不能保证你的设计在未来的扩展中是正确的(举例就是业务可能只需要正常流程,但是没有异常流程的需求)。

所以,如果要代码能实现所有计划的功能,就要由开发者来编写其对应的测试模块。因为是开发人员,所以知道自己的所有逻辑组合是什么,而根据这种需求编写的测试代码则可以长久地对你的系统进行测试。而这就是就是:

TDD(测试驱动开发)

测试驱动开发中最主要的准则是:

在编写业务代码之前先编写单元测试。

这条准则的目的在于:不要编写没有单元测试的代码。实际上我们在编写功能业务的时候,一般都会假设一个入口,然后再经过一段逻辑处理之后,最终返回一个结果。而先编写单元测试的目的,就是先将你的这个假设直接落地到代码中,那么在后续的编程过程中你就可以忽略这部分的假设,专注于逻辑编写,甚至即使你最后忘记了之前的假设也不要紧,因为你已经将他写道代码中了。

而这一准则的进一步拆解,可以将其细化为三个准则:

  1. 在编写不能通过的单元测试前,不可以编写生产代码。
  2. 只可编写刚好不能通过的单元测试,不能编译也算。
  3. 只可编写刚好足以通过当前失败测试的生产代码。

根据细分的这三个准则,我们可以将我们编写一个逻辑的的步骤变成:写一个刚好失败的单元测试,然后用刚好满足逻辑的生产代码满足它。这样一个小循环可能只是在一两分钟内就进行一次。而在IDEA等现代IDE的帮助下,可以在test包下的同包路径创建对应的测试方法,大大加快了单元测试的编写时间。而通过刚好的异常,让每一次的业务逻辑得到了控制,通过刚好满足则让每一次生产代码的编写不会过度发散。因为如果你对生产代码过度设计,那么你也需要对应的单元测试代码来保证你设计的的得当性。

如果按这种循环进行编写,则我们在编写业务代码的同时只需要多十几秒就可以完成单元测试的编写。而单元测试可以完整地覆盖业务单元元。但是随着业务代码的增加,测试代码的数量也将急剧增加,其对应的管理也是一种挑战。

系统进化的保证

回到最开始的例子,我们说在一些团队中,我们总是觉得单元测试是低效的,会影响业务的上线速度。我们也说这种方法看起来没什么不好的,直到最终的问题发生。而这最终的问题就是:重构。

这里说的重构并不一定是大范围的整体系统重构。我们在之前的文章《如何阻止软件退化》中提到:要保持软件设计质量不退化,必须要在每次需求变更的时候,根据变更点调整原有程序的设计结构。

而当我们相对原有的程序结构进行调整的时候,我们无法确保对代码的改动能如预期的工作,也无法保证系统中的某个修改点是否会影响到系统的其他部分。举个例子:当你会支付的路由进行修改的时候,如果出现意外则会导致其他支付方式的失败,但是如果确保功能正常则你需要将所有的支付逻辑都进行一遍功能测试,而仍然可能存在功能点的遗漏(这个是亲身经历)。因为害怕新增加的功能会带来更多的bug导致加班,最终的结论就是我们可能会抗拒对功能结构的调整,而变成所谓的“屎上雕花”。所以从这个角度上来说,如果没有单元测试,则软件将不可避免地直线的退化。

而相反地来说,如果我们的系统包含单元测试。我们才不用担心对于代码的修改,每一次调整都能通过那些“刚好”的单元测试,那么不论你如何进行设计模式的重构,都不用担心引入新的不可预知的缺陷。

所以当有了单元测试,才能让我们的系统有了进一步的维护性、扩展性,也才有了系统进化的可能。

应该被重视的单元测试

我们需要单元测试来保证系统功能的扩展性、可维护性。但是这并不意味着,只要有单元测试就可以。事实上我们应当如同生产代码一样的重视单元测试。原因很简单:

单元测试代码同样会随着功能调整而变得腐化。

而如果当它腐化的难以维护的时候,谁都不会愿意去修改它。最终的结果就是我们不用单元测试了,然后失去了代码的扩展性。所以测试代码必须要随业务代码的修改而同步修改,并不能因为单元测试只运行在测试环境而轻视单元测试的编写,我们同样需要让单元测试的代码也足够整洁,让其便于维护。

测试的逻辑

单元测试的时候重要的是体现出当前进行的测试内容,而让别人理解测试内容的重中之重是测试“可读性”。如果单元测试的代码中充满的一长串的业务逻辑或者断言内容,那么读起来就会十分的费劲。为了避免开发人员淹没在代码的细节中,有一种较为公认的单元测试的构造方法:构造-操作-检验(BUILD-OPERATE-CHECK),并使用give-when-then的命名方式来进行命名。

举一个例子(这里直接用的CLEAN CODE的例子了):

givenPages(xxx);
whenRequestIsIssued(xxx);
thenResponseShouldBeXML();
  • 1.
  • 2.
  • 3.

其中,第一部分将构造测试数据的内容分装到given开头的方法中;第二部分将操作测试数据的内容封装到when开头的方法中;第三部分将检查操作是否得到预期的结果封装到then开头的方法中。

这样就屏蔽了绝大部分的代码细节,并用方法的名称直接描述了测试的前置条件、处理过程、判断结果。同时当我们涉及到一些复杂流程的判断的时候,我们是可以单独为单元测试来编写一部分额外的方法来支撑单元测试。这样可以让人变这样可以让人快速地理解单元测试的逻辑。

可以放松的部分

尽管说我们需要让单元测试保持代码保持整洁,并需要向生产代码一样地重视它。但并不意味着我们的测试代码和生产代码的准则是完全一样的。因为单元测试的准则是具有可读性的代码并能精准地描述关注的测试功能边界。

所以有一些内容是不需要和生产代码保持一致的。其中最明显的就是性能要求。

我们在线上代码中需要对系统性能进行各种优化,但是单元测试的代码是跑在测试环境中并且单个逻辑每次只执行一遍,对单元测试来说,0.1ms的逻辑和1ms的逻辑差距可能并不明显。这样的情况下我们可能会选用一下表达能力更强的方法来进行项目的编写比如使用"+"号对字符串进行拼接,我们一般都会用StringBuilder,但是不得不说直接使用“+”拼接的实现可读性更高一点。除此之外还有一些异步的功能可以使用串行化来校验,以便校验每一步的结果。

单一概念

为了保证每一个单元测试中逻辑的可读性,所以我们希望每一个单元测试只对一个概念进行测试,这样就可以用一组give-when-then的方法来对这个测试概念进行描述。当我们发现单元测试存在多个概念的时候就会将他们拆开分别进行测试。这样就避免了多个概念聚合在一个单元测试方法中的时候,会犹豫复合概念导致掩盖了一些遗漏的测试点在其中。同时也保证了单元测试的可读性。

其他原则

除此之外,单元测试还要保证:

  • 快速性:单元测试可快速执行,支持频繁测试。
  • 独立性:单元测试不互相依赖,随时以任意顺序执行。
  • 可重复性: 单元测试可以反复执行且结果统一,否则永远会有功能失败的借口。
  • 可检验:单元测试要明确地通过布尔值来表示检测结果,而非通过其他诸如日志的辅助手段。
  • 及时性:要在开始编写业务代码前编写,让业务代码去覆盖测试。

最后

本文讨论了单元测试的必要性以及单元测试中的一些重点注意实现。有人会觉得单元测试影响开发效率,但是站在项目管理者的角度上来说,有了单元测试项目才有了持续进步的可能。