两大绝招,教你为大型项目编写单元测试

2023-03-23 18:20:02 浏览数 (1)

多年前,我作为敏捷教练负责提升一个大型系统的代码质量。我采用的一个有效手段是带领团队编写单元测试,一方面可提升测试覆盖率,另一方面则通过编写测试提升代码的可测试性,进而让代码变得松耦合,职责的分配也变得更加合理。

推进过程自然困难重重,最大的障碍还是该系统的规模太大,代码质量太糟糕。为了更好地洞察代码状态,我通过SonarQube分析了该项目。由于规模太大,分析的机器也不太给力,整个代码静态分析耗费了惊人的1:58:52.282秒

下图为分析结果截图:

统计数据如下:

  • 代码行:459万多
  • 类的数量:3万多
  • 违反Issue规范数量:近52万
  • 单元测试覆盖率:0.1%

深入代码库,你能想到的代码坏味道几乎都具备了,完全是活生生的臭味博物馆,包括:

  • 超长方法
  • 超大的类
  • 复杂的分支语句
  • 暴露过多细节
  • UI与业务逻辑耦合
  • 庞大的Utility类
  • 依赖紧耦合
  • 混乱的包结构

面对如此混乱而又规模庞大的遗留系统,该如何编写单元测试,并提升系统的测试覆盖率?

不同场景和不同需求,有不同的绝招。

绝招一:另辟蹊径

如果要在现有系统中添加新的功能,即使添加的新代码“生长”在这个庞大的遗留系统之上,只要新功能具有独立性,也可以将其视为新项目,可在没有任何技术债的基础之上开展测试驱动开发。采用了测试驱动开发,那就天然促进了单元测试的覆盖率。

首先,保持旧代码不动;然后,在项目中单独创建一个新模块,按照测试驱动开发的节奏开展新功能代码的编写。一旦新功能编写完毕,再找到旧代码需要增加新功能的地方,增加对新功能的调用,而调用代码则属于旧代码的一部分。

我将这一绝招称之为另辟蹊径。

当初在这个百万行代码的项目上,开发人员接手了一个新功能,要增加对新设备数据的流量控制验证。在原有代码库中,流量控制的功能放在一个庞大的类中,依赖复杂,特别还依赖了许多底层的框架。要彻底解耦,费时费力,而不解耦呢,在没有提供复杂的集成环境下,几乎没有办法运行测试。

采用另辟蹊径的做法,就能绕开庞大代码库的债务,新建的一个模块干干净净。运用实例化需求的方法,我们对新功能的验证规则进行分解,定义测试用例,开展测试驱动。

由于验证规则比较复杂,需要支持各种规则的独立演化与组合。遵循面向对象设计原则,引入策略模式为各个验证规则定义了对应的类,又引入装饰器模式以支持规则的组合。

通过测试逐步驱动出这些规则之后,对外,我们定义了TrafficParamValidator类,形成流量验证的门面类。再回到旧代码处,找到调用点,新增加一个分支语句,以支持新设备类型。分支语句的内容非常简单,就是发起对TrafficParamValidator对象的调用即可。

如果该独立的代码并非新功能,而是旧代码,也可采用这一方式。只要剥离出该独立功能,就可以将它对应的旧代码彻底抛弃掉,直接通过测试驱动开发进行重写,实现完成后,再到旧代码的调用点发起对新代码的调用。

例如,当时我们需要针对该项目的一个时钟视图ClockView添加新功能。在这个视图对应的Pannel类中,既包括刷新网元的功能,又包括刷新光纤的功能,二者混合在一起。

现在,需要改进刷新光纤功能的代码。

我们通过内联方法的重构方式,先将这两个功能放到一个大方法内,然后在这个方法内部调整调用顺序,使得这两个功能在逻辑上可以完全独立(例如,各自使用自己的变量),再各自提取方法,使得代码结构更加清晰。

接下来,调整刷新光纤的代码实现。由于该功能的实现逻辑非常复杂,不易于维护,重构也有很大的难度。此时,可以将刷新光纤状态的功能视为新功能,另起炉灶,单独为它建立一个新的模块,开展测试驱动开发,并对外定义一个门面类LinkStatusRefresher供旧代码调用。

这一方式事实上为新旧代码搭建了一层薄薄的墙,做到了新旧世界的巧妙隔离。同时,它抛弃了旧有代码欠下的债务,也不必承受重构复杂遗留代码的成本,推进测试驱动开发也变得容易起来。

绝招二:解除耦合

如果无法绕开旧代码,要为遗留功能编写单元测试,需要求助的绝招就是解除耦合。

知易行难。由于大多数质量差的遗留代码就像一盘意大利面条,逻辑混乱,没有清晰的边界,依赖如网一般相互纠缠。要理清这团乱麻,需要花费很大的精力。

真正的单元测试,不应该依赖任何外部环境,不管是外部的容器、框架、平台,还是数据库、网络等资源,原则上都不应该依赖。如果真的依赖了调用外部环境的类,就需要采用模拟的方式。

倘若设计皆遵循依赖倒置原则,并采用依赖注入的方式形成对象之间的协作,模拟就变得格外容易。当然,在模拟类时,要注意使用静态块的情况。例如有一个ErrorInfo类,它依赖了ErrorCodeI18n类:

代码语言:javascript复制
public class ErrorInfo {
    public void setErrorCodeI18n(ErrorCodeI18n codeI18n) {
        this.errorCodeI18n = codeI18n;
    }

    public ErrorInfo(int category, int errorCode) {
        m_category = category;
        m_errorCode = errorCode;
        convErrorCode();
        convDebugInfo();
    }

    private void convDebugInfo() {
        ErrorItem item = errorCodeI18n.getErrorItem(m_category, m_errorCode);
    }
}

代码采用依赖注入来管理ErrorInfo类和ErrorCodeI18n类之间的依赖。可惜,由于ErrorCodeI18n类的内部定义了执行初始化的静态块,而静态块的实现又依赖了外部资源,如果直接模拟ErrorCodeI18n类,并不能斩断对外部资源的依赖。

此时,可以为ErrorCodeI18n提取接口,然后针对接口进行Mock。

注意,在提取接口时,需要从调用者的角度考虑接口的方法和名称,不要一股脑儿将目标类的所有公有方法都提取到接口中。以ErrorCodeI18n为例,我们发现调用者之所以要调用它,目的是通过它获得ErrorItem,因此提取的接口定义为:

代码语言:javascript复制
public interface ErrorItemSupport {
    ErrorItem getErrorItem(int category, int errorCode);
}

原有的ErrorCodeI18n和ErrorInfo就修改为:

代码语言:javascript复制
public class ErrorCodeI18n implements ErrorItemSupport {}

public class ErrorInfo {
    public void setI18nService(ErrorItemSupport errorItem) {
        this.errorItem = errorItem;
    }
}

提取接口的手段非常简单,如IntelliJ IDEA这样的IDE直接支持这一重构手法。

然而,也有一部分开发人员并没有采用依赖注入管理对象协作的习惯,也忽略了降低耦合度的重要性,因此,在遗留代码中,往往会出现大量对静态方法的调用,为了方便,还会直接在方法中实例化外部类。

这个时候,就需要利用接缝(seam)。

接缝的概念来自《修改代码的艺术》,其定义为:

指程序中的一些特殊的点,在这些点上你无需作任何修改就可以达到改动程序行为的目的。

怎么理解?

还是针对ErrorCodeI18n的调用,在遗留代码某个类的convDebugInfo()方法中,直接创建了ErrorCodeI18n实例:

代码语言:javascript复制
private void convDebugInfo() {
    ErrorItem item = ErrorCodeI18n.getInstance().getErrorItem(category, errorCode);
    //.…..
}

ErrorCodeI18n的getInstance()实现非常复杂,其内部也依赖了外部资源。为了隔离对getInstance()的调用,就可以通过接缝方式,在convDebugInfo()方法中,对获得ErrorCodeI18n实例的代码进行方法提取:

代码语言:javascript复制
private void convDebugInfo() {
    ErrorItem item = getErrorCodeI18n().getErrorItem(m_category, m_errorCode);
}
protected ErrorCodeI18n getErrorCodeI18n() {
    return ErrorCodeI18n.getInstance();
}

对ErrorInfo编写测试时,就可以通过重写getErrorCodeI18n()方法,返回一个假的ErrorCodeI18n对象:

代码语言:javascript复制
@Test
public void testMethod() {
    ErrorInfo errorInfo = new ErrorInfo() {
        @Override
        protected ErrorCodeI18n getErrorCodeI18n() {
            return new FakeErrorCodeI18n();
        }
    }
}

当然,如前所述,采用子类重写的方式依然绕不开静态块的问题,这时,还是需要为ErrorCodeI18n提取接口,然后在测试方法中,创建该接口的模拟对象。

0 人点赞