工程质量-06-第二阶段:以测试为核心的基本流程

建立了代码审查制定之后,会马上遇到一个关键的问题:质量到了一定程度上很难提升了。代码审查的质量严重依赖于参与者的投入程度,但即使是最认真的严格的审查者,也有状态不好的时候。我们虽然可以定制一系列的代码审查规范,代码编写规范或者再引入一些静态检查工具,这一些或多或少会对代码质量有一定的提升,但是这些机制并不能够根本解决人在审查过程中效率的客观不稳定性。

因此在第二阶段,我们会引入以测试为核心,特别是单元测试为核心的流程。

以下我都会使用单元测试来代指测试,实际上单元测试还包括集成测试、端到端的测试三类测试,本质上是一样的,只是处理的问题范围大小不同。

graph LR
A[测试]-->B[单元测试-Unit Testing]
A-->C[集成测试-Integration Testing]
A-->D[端到端测试-End to End Testing]

这一个阶段引入的单元测试之后,缺陷清除率如下所示

Removal Step Lowest Rate Modal Rate Highest Rate
非正式的设计审查(Informal design reviews) 25% 35% 40%
非正式的代码审查(Informal code reviews) 20% 25% 35%
静态代码分析(Static code analysis) 5% 10% 15%
单元测试(Unit test) 15% 30% 50%
集成测试(Integration test) 25% 35% 40%
系统测试(System test) 25% 40% 55%
缺陷排除预期累计效率值 72.74% 88.02% 95.52%

6.1. 为什么是单元测试

如果五年前,让我来来回答如何提高工程质量,我一定不会将单元测试放在这里。我直觉的认为单元测试需要两个很重要的缺陷

  1. 单元测试需要花很多时间。少量的单元测试意义并不是需要大,要想让单元测试达到一个可用的效果需要投入大量的时间,这可能比开发编码的时间还长,这看起来是得不尝失的。不仅仅产品、市场很难接受,研发人员本身也很难接受这种投入。
  2. 单元测试依赖的代码系统架构需要是稳定的,不会有特别强烈的变化,基本架构变了,配套的单元测试也需要变化,维护单元测试将会变得很难。现在我们的软件迭代都非常快,没有真正稳定的架构,变化本身是一种需求。

但是随着对研发本身以及单元测试的深刻了解,我的观念有了根本性的变化。单元测试有几个非常重要的好处

6.1.1. 单元测试可以有效的降低缺陷率

降低缺陷率是我们写单元测试的最本质的驱动力。

6.1.2. 单元测试可以节省研发时间

这个观点与我最初的直觉是相反的。单元测试节省研发时间可以从两个方面来看。

首先,一个正常的项目,一般的生命周期至少两到三年起步,这中间会迭代无数次的版本,代码量也会越来越多。如果在这无数次的版本迭代过程中

这一点会极大的影响我们的效率。如果我们没有这样的机制,我们就没有办法从根本上避免一些问题的反复出现。如果我们将问题全部丢给测试、丢给代码审查的话。那么我们其实很难保证新开发的功能是否影响了原有的功能,新修改的BUG是否又引起了新的BUG。这需要投入更多的测试资源和时间来保证。对于单次具体的研发过程而言,单元测试可以有效的降低提交测试的BUG率。这会让测试这边少花时间和资源,这会极大的节省时间。

6.1.3. 单元测试可以有效的保持代码结构合理性

单纯的随便写写单元测试并不难,但是想要写好、管理好单元测试,让单元测试发挥它的功能作用,是一件非常难的事情。后面在如何写单元测试这一部分我会详细说明这里面的一些难点和最佳实践。

好的单元测试是对整体的代码结构是有很高的要求的。我们学习过很多关于如何写好代码的理论规范,比如说尽量不要有重复代码、一个函数不要过长、函数的参数不要过多,模块与模块之间高类聚、低耦合、面向对象的SOLID原则、23种设计模式… …等等。

这些规范和最佳实践听起来都很有道理,但是在实际写代码的过程中发现,我们大部分编程都是面向解决问题的思维。很多时候只要满足产品需求,其它的约束并不是那么重要,只有在我们的代码已经乱成一团遭的时候,才会想到当时要能够多规范一下多好。

这些都是隐性的约定,都是对编程者的道德约束,并不是法律要求。在能够解决问题的前提下,要求过多,反而会有吹毛求疵、故意找茬的不利影响。更无赖的是,有一些编程者使用了这些机制,但是其他人不理解,会反而觉得有过度设计、炫技的感觉。

造成这种问题的核心原因是我们没有将单元测试当成一个必须的功能需求来完成它。如果项目里面要求写完整的单元测试,你就会发现,上面讲的那些理论和设计模式真的是很有用。你都不需要刻意去学习这些理论,只需要为一个项目完整的写单元测试,抱着完成单元测试这个需求的角度,这些理论自然就应用上了。

业内其实有很多资料都在强调单元测试的重要性

6.2. 单元测试的理论分析

要写好一个单元测试并不简单,这里面涉及到大量的权衡,单元测试会对业务侧的代码与模块之间的关系和实现有非常深刻的影响。对于单元测试我强烈推荐一本由Vladimir Khorikov于2020年写的<Unit Testing Principles, Practices, and Patterns>[14] ,这一本书从理论上详细的回答了:什么是好的单元测试、怎么评估一个单元测试,怎么样才能够写好单元测试这些基本问题。下面的部分很多都是来自于这一本书的内容。

一个好的单元测试,它会有四个重要的特征

6.2.1 单元测试的精度指标

预防BUG(Protection against regressions)、抗重构(Resistance to refactoring)这两个要素可以用来评估单元测试的精确度。如下所示

image

在这种情况下,我们可以定义单元测试的精度,根据机器学习里面的理论我们可以这样定义。

image

但是很多时候,作者在书里面这样定义的精度

image

6.2.2. 单元测试的约束性关系和得分

单元测试能够完全满足上面的四个属性么?答案是不能。在上面四个属性里面有三个属性是有制约关系的。

image

如上图所示

存在同时有三种特性的单元测试么?并没有,那么永远并不能够达到的领域。这和数据库领域里面的CAP原则是一样的,你只能牺牲其中一点,扩大其它两个属性。

除了上面的关系之外,还有如下的关系

image

image

上面的图,我们可以清楚的看到。越选择快速反馈,这个测试就越像单元测试。越选择预防BUG,这个测试就越像端到端的测试。在中间水平的就是集成测试。

要衡量一个单元测试的评分,可以将上面的四个维度相乘。这也算是一个评估方法,这种方法告诉我们,上面四个点,不能够放弃任何一点。任何一个方面得零分,这个单元测试就得了零分。

6.2.3. 单元测试覆盖率

提到衡量单元测试,我们会天然的想到使用单元测试的覆盖率来衡量。单元测试的覆盖率一般有两类。

覆盖率是一个可以用证伪,但是不能够用来证真的指标。例如下面的代码

bool IsStringLong(const std::string input) {
    return input.size() > 20;
}

// Unit Testing
void Test_String_Too_Long() {

    std::string a = "abc";

    bool res = IsStringLong(a);
    
    ASSERT(res == false);
}

上面的IsStringLong的实现非常简单,下面的单元测试也验证了一种情况。从代码覆盖率来讲,已经达到100%了。但是实际上,这个单元测试只是验证了一种情况,还有大于20个字符的情况没有验证。

覆盖率只能够证伪不能够用来证真意味着。如果一个项目的单元测试覆盖率低于50%,这一点可以证明这个项目的单元测试一定是不够的。但是当一个项目的单元测试覆盖率达到90%,并不能够证明这个项目的单元测试已经很完善了。

6.3. 单元测试的最佳实践

前面说过,单元测试的一个最重要的优点之一是可以让整体的代码结构变得更好。一个易于单元测试的代码整个的风格将会是优雅的。这里面就会涉及到很多关键点。

6.3.1. 测试的内容:有重点的测试

单元测试的第一个最佳实践就是需要测试那些投入产出比非常高的代码里面。一个项目里面的代码按照代码复杂程度和与第三方依赖程度,可以划分为四类。

image

如何对这些代码模块进行单元测试呢?

image

测试领域模型的代码,由于不与第三方进行交互,这一部分一般称为单元测试。而测试与第三方交互的代码测试的时间比较长,跨度的内容比较多。这些测试一般被称为集成测试

image

6.3.2. 测试的内部结构-AAA结构

单元测试它有三个特点

对于一个项目来说,代码是负债,只要写过的代码都是需要维护的。单元测试的代码也是代码,也是需要维护。一个单元测试的代码应该需要非常简单,简单到它不应该出什么问题,只有这样,维护单元测试才不会有压力。单元测试的代码不需要什么创新,直接同一种模式就可以了。

现在的单元测试最佳实践就是一个AAA结构。一个单元测试就分为三个部分

如下代码所示,下面就是一个简单的单元测试

int Sum(int a, int b) {
    return a + b;
}

void Testing_Sum_of_Two_number() {
    // Arrange
    int a = 10;
    int b = 20;
    
    // Action
    int res = Sum(a, b);
    
    // Assert
    ASSERT(res == 30);
}

上面的单元测试就是一个典型的AAA模型。使用AAA模型还有几个注意的点

在有一些场景之下,一次测试可能会有多个流程,很多人天然的可能会将单元测试写成如下的形式

image

除了实现没有办法的情况下,不要使用这种形式。出现这种情况优先建议将一个单元测试拆分成多个单元测试来处理。还是那一个原则,单元测试一定要简单,简单到连变量的命名都使用同一个。这样的单元测试不容易出问题,同时所有人都很容易看懂。

6.3.3. 只验证结果,不关注实现细节

单元测试测试的是一个业务行为,而不是限于一个简单的函数。简单的函数如前面举的Trivail Test一样,测试的价值不大。它本身实现非常简单,出问题的概率特别小。所以,单元测试以业务行为为角度进行测试,一次测试内部可能会涉及到很多代码,这才是一个单元。

为了保证单元测试能够抗重构,我们在测试的时候需要只关注结果,而不要关注实现的细节。如下面的代码所示

class RenderHtml {
    public:
    RenderHtml(const std::string header, const std::string body, const std::string foot){
        msg[0] = header;
        msg[1] = body;
        msg[2] = foot;
    }
    std::string Render() {
        std::string res = "<h>" + msg[0] + "</h>";
        res += "<body>" + msg[1] + "</body>;
        res += "<i>" + msg[2] + "</i>";
        return res;
    }
    public:
    std::string msg[3];
    // std::string header_;
    // std::string body_;
    // std::string foot_;
}

void Testing_Normal_Render() {
    // Arrange
    std::string header = "H";
    std::string body = "B";
    std::string foot = "F";
    RenderHtml sut(header, body, foot);
    
    // Act
    std::string res = sut.Render();
    
    // Assert
    ASSERT(res == "<h>H</h><b>B</b><i>F</i>");
    ASSERT(sut.msg[0] == header);
    ASSERT(sut.msg[1] == body);
    ASSERT(sut.msg[2] == foot)
}

上面的测试中,在Assert阶段,一共验证了四个部分。第二到第四部分的代码去校验了RenderHTML里面的实现细节。这样的单元测试是非常利于于重构的,如果未来需要对RenderHTML进行重构,那么这个单元测试就跑不起来了。只有第一部分的验证是正确的,只去关注结果,不要关注细节。

上面这个代码看起来很蠢,我刻意的定义了一个msg[]数组,用来演示细节的问题,正常情况下很少有会这样实现和测试,但是这是一个现象,很多人写单元测试的时候会自然的想验证越多的结果越好,越精确,这样关关注于实现,反而会起到相反的副作用。

这是一个示例,我们需要关注的是结果,而不是关注内部的实现细节。这样的单元测试才是好的单元测试,才能够提供最正确的结果。

6.3.4. 各类测试的占比

前面的内容你已经知道了,测试按照测试的范围不同,包含了三类测试

这三类测试在一个完整的项目中占比应该是显现一个金字塔结构,如下图所示

image

也就是说,项目中大部分的测试应该是单元测试,其次有一部分是集成测试,最后才是少量的端到端测试。虽然集成测试和端到端测试验证的业务逻辑比较多,看起来投入产出效率很高。但是这种测试一般情况下执行起来都比较复杂,特别是依赖第三方系统,所以不利于在开发过程中经常跑。

Google公布自己内部统计的结果显示。

image

单元测试占比80%,集成测试有15%左右,而端到端的测试需要大量的资源调度只点整体测试的5%。所以,正常情况下,优先写最简单的单元测试,单元测试能够保证简单和高效。这一块做好了,集成测试和端到端测试可以放到CICD流程上面集中来做。

这种测试的占比,其实是对项目的代码是有一定要求的。现在的代码经常涉及到与第三方的对接,比如网络、数据库等交互。要想保持这种结构,我们在项目的架构中就需要比较严格的区分领域业务代码与第三方对接者之间的联系。

image

如上图所示

当然也不是所有的项目都需要呈现这样明显的金字塔结构,有一些项目比如大部分的逻辑就是简单的CURD操作数据库相关的。这种情况下集成测试做多一些也没有问题。但是要明白,这是少数现象。

6.3.5. 关于Mock的使用

Mock是单元测试中非常重要的辅助工具,各类语言里面都有相应的Mock工具。Mock主要是将一些依赖的实现Mock一个虚拟的实例,这样方便进行单元拆分隔离测试。Mock工具容易引起的问题是Mock滥用。Mock只去模拟第三方组件库,而且只去模拟那第不能够为这个测试独享的依赖组件

类与类之间可以使用虚拟类来进行解耦,接口与接口之间可以使用回调函数来进行解耦。Mock可以实例化一个虚拟类,这样可以方便测试。也可以在测试中集成实现一个类来替代Mock技术,实现更深度的检查。

Mock技术只检查一个函数的调用次数,不要检查其它的细节。细节检查越多,就越无法对抗未来的重构。

6.4. 引入单元测试的步骤

单元测试在国内的普及度并不高,要做好单元测试第一步是需要在团队内将单元测试的相关理论拉齐,在这个基础之上再结合实践逐步的完善。

在单元测试的理论方面,我依然强烈推荐Vladimir Khorikov写的<Unit Testing Principles, Practices, and Patterns>一书。能够系统性的集体学习分享一下是最好的。如果没有条件,我前面介绍的单元测试相关内容其实已经足够能够涉及到初步的理论了。在这个基础之上,可以结合《重构-改善既有代码的设计》这一本书里面讲的一些方法来进行逐步的添加单元测试。

6.4.1. 什么时候加入单元测试

这里面主要分为两种情况,如果你现在负责的是一个全新的项目。希望你能够从头就开始,这一部分可以考虑使用测试驱动开发(TDD)的红、绿、重构三个步骤。

image

对于已经存在的代码,显然不建议进行大规模的添加单元测试,这很难实现。前面的理论已经讲过,单元测试会对代码的整体结构有深刻的影响和要求。如果这个项目以前没有怎么考虑单元测试,现在突然想添加单元测试,涉及到的修改将会是特别大的。

在这种情况下,可以根据《重构》这一本书描述的事件点来进行逐步添加单元测试,随着项目的不段迭代,最终将这个项目的单元测试完成

6.4.2. 将单元测试也加入到CICD流程中去

每一次代码审查之前,都需要先过单元测试。这样代码审查起来不会关注一些比较小的问题。

image

单元测试会极大的影响整体的代码结构,这一点对提供整体工程的质量是非常重要的。它并不是简单的能够降低BUG率,更重要的是,通过单元测试对整体工程的调整,我们的整体代码会变得极其有结构。

上面的单元测试理论涉及很多。但是其实单元测试很简单,只要你将单元测试当成一个必须要完成的需求,随着一步一步的实践和探索,你一定会慢慢总结出单元测试的理论的。

这和学习一门语言的语法一样,语法规则比较复杂,但是将这一门需要放到编译器上面,它会告诉你哪些地方语法有问题。多写一些程序,久而久之,这门编程语言的语法就学会了,都不需要刻意的去记忆。

单元测试也是如此,你只要为你的团队定好目标。比如单元测试的覆盖率必须达到85%以上,然后再注意单元测试的四个特性,在代码审查的过程中确保单元测试的四个特性得到了支持。随时时间的推移,单元测试就能够很快的普及下去。

6.5. 成败的关键

引用