前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >两大绝招,教你为大型项目编写单元测试

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

作者头像
张逸
发布2023-03-23 18:20:02
3500
发布2023-03-23 18:20:02
举报
文章被收录于专栏:斑斓斑斓
多年前,我作为敏捷教练负责提升一个大型系统的代码质量。我采用的一个有效手段是带领团队编写单元测试,一方面可提升测试覆盖率,另一方面则通过编写测试提升代码的可测试性,进而让代码变得松耦合,职责的分配也变得更加合理。

推进过程自然困难重重,最大的障碍还是该系统的规模太大,代码质量太糟糕。为了更好地洞察代码状态,我通过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提取接口,然后在测试方法中,创建该接口的模拟对象。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-02-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 逸言 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 绝招一:另辟蹊径
  • 绝招二:解除耦合
相关产品与服务
腾讯云服务器利旧
云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档