1
4006-998-758
新闻动态

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

2023-02-28

张逸——DaoCloud数字应用现代化首席顾问,《解构领域驱动设计》和《软件设计精要与模式


》作者,高质量编码实践者,微服务系统架构师,大数据平台架构师,敏捷转型咨询师。致力于


将企业架构、敏捷管理、云原生架构、领域驱动设计与大数据架构融入到应用现代化方法体系中


,帮助企业实现数字化转型和数字化创新。


多年前,我作为敏捷教练负责提升一个大型系统的代码质量。我采用的一个有效手段是带领团队


编写单元测试,一方面可提升测试覆盖率,另一方面则通过编写测试提升代码的可测试性,进而


让代码变得松耦合,职责的分配也变得更加合理。


推进过程自然困难重重,最大的障碍还是该系统的规模太大,代码质量太糟糕。为了更好地洞察


代码状态,我通过SonarQube分析了该项目。由于规模太大,分析的机器也不太给力,整个代码


静态分析耗费了惊人的1:58:52.282秒。


统计数据如下:

代码行:459万多

类的数量:3万多

违反Issue规范数量:近52万

单元测试覆盖率:0.1%

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

超长方法

超大的类

复杂的分支语句

暴露过多细节

UI与业务逻辑耦合

庞大的Utility类

依赖紧耦合

混乱的包结构

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


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


                                                    — 绝招1.另辟蹊径 —


如果要在现有系统中添加新的功能,即使添加的新代码“生长”在这个庞大的遗留系统之上,只


要新功能具有独立性,也可以将其视为新项目,可在没有任何技术债的基础之上开展测试驱动开


发。采用了测试驱动开发,那就天然促进了单元测试的覆盖率。


首先,保持旧代码不动;然后,在项目中单独创建一个新模块,按照测试驱动开发的节奏开展新


功能代码的编写。一旦新功能编写完毕,再找到旧代码需要增加新功能的地方,增加对新功能的


调用,而调用代码则属于旧代码的一部分。


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


当初在这个百万行代码的项目上,开发人员接手了一个新功能,要增加对新设备数据的流量控制


验证。在原有代码库中,流量控制的功能放在一个庞大的类中,依赖复杂,特别还依赖了许多底


层的框架。要彻底解耦,费时费力,而不解耦呢,在没有提供复杂的集成环境下,几乎没有办法


运行测试。


采用另辟蹊径的做法,就能绕开庞大代码库的债务,新建的一个模块干干净净。运用实例化需求


的方法,我们对新功能的验证规则进行分解,定义测试用例,开展测试驱动。


由于验证规则比较复杂,需要支持各种规则的独立演化与组合。遵循面向对象设计原则,引入策


略模式为各个验证规则定义了对应的类,又引入装饰器模式以支持规则的组合。


通过测试逐步驱动出这些规则之后,对外,我们定义了TrafficParamValidator类,形成流量验证


的门面类。再回到旧代码处,找到调用点,新增加一个分支语句,以支持新设备类型。分支语句


的内容非常简单,就是发起对TrafficParamValidator对象的调用即可。


如果该独立的代码并非新功能,而是旧代码,也可采用这一方式。只要剥离出该独立功能,就可


以将它对应的旧代码彻底抛弃掉,直接通过测试驱动开发进行重写,实现完成后,再到旧代码的


调用点发起对新代码的调用。


例如,当时我们需要针对该项目的一个时钟视图ClockView添加新功能。在这个视图对应的


Pannel类中,既包括刷新网元的功能,又包括刷新光纤的功能,二者混合在一起。


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


我们通过内联方法的重构方式,先将这两个功能放到一个大方法内,然后在这个方法内部调整调


用顺序,使得这两个功能在逻辑上可以完全独立(例如,各自使用自己的变量),再各自提取方


法,使得代码结构更加清晰。


接下来,调整刷新光纤的代码实现。由于该功能的实现逻辑非常复杂,不易于维护,重构也有很


大的难度。此时,可以将刷新光纤状态的功能视为新功能,另起炉灶,单独为它建立一个新的模


块,开展测试驱动开发,并对外定义一个门面类LinkStatusRefresher供旧代码调用。


这一方式事实上为新旧代码搭建了一层薄薄的墙,做到了新旧世界的巧妙隔离。同时,它抛弃了


旧有代码欠下的债务,也不必承受重构复杂遗留代码的成本,推进测试驱动开发也变得容易起来



                                                    — 绝招2.解除耦合 —


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


知易行难。由于大多数质量差的遗留代码就像一盘意大利面条,逻辑混乱,没有清晰的边界,依


赖如网一般相互纠缠。要理清这团乱麻,需要花费很大的精力。


真正的单元测试,不应该依赖任何外部环境,不管是外部的容器、框架、平台,还是数据库、网


络等资源,原则上都不应该依赖。如果真的依赖了调用外部环境的类,就需要采用模拟的方式。


倘若设计皆遵循依赖倒置原则,并采用依赖注入的方式形成对象之间的协作,模拟就变得格外容


易。当然,在模拟类时,要注意使用静态块的情况。例如有一个ErrorInfo类,它依赖了


ErrorCodeI18n类:


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,因此提取的接口定义为:


public interface ErrorItemSupport {

    ErrorItem getErrorItem(int category, int errorCode);

}

原有的ErrorCodeI18n和ErrorInfo就修改为:


public class ErrorCodeI18n implements ErrorItemSupport {}


public class ErrorInfo {

    public void setI18nService(ErrorItemSupport errorItem) {

        this.errorItem = errorItem;

    }

}

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


然而,也有一部分开发人员并没有采用依赖注入管理对象协作的习惯,也忽略了降低耦合度的重


要性,因此,在遗留代码中,往往会出现大量对静态方法的调用,为了方便,还会直接在方法中


实例化外部类。


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


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


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


怎么理解?


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


ErrorCodeI18n实例:


private void convDebugInfo() {

    ErrorItem item = ErrorCodeI18n.getInstance().getErrorItem(category, errorCode);

    //.…..

}

ErrorCodeI18n的getInstance()实现非常复杂,其内部也依赖了外部资源。为了隔离对


getInstance()的调用,就可以通过接缝方式,在convDebugInfo()方法中,对获得


ErrorCodeI18n实例的代码进行方法提取:


private void convDebugInfo() {

    ErrorItem item = getErrorCodeI18n().getErrorItem(m_category, m_errorCode);

}

protected ErrorCodeI18n getErrorCodeI18n() {

    return ErrorCodeI18n.getInstance();

}

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


ErrorCodeI18n对象:


@Test

public void testMethod() {

    ErrorInfo errorInfo = new ErrorInfo() {

        @Override

        protected ErrorCodeI18n getErrorCodeI18n() {

            return new FakeErrorCodeI18n();

        }

    }

}

当然,如前所述,采用子类重写的方式依然绕不开静态块的问题,这时,还是需要为


ErrorCodeI18n提取接口,然后在测试方法中,创建该接口的模拟对象。




对于现在的前端工程,一个标准完整的项目,测试是非常有必要的。单元测试的目的不是为了代


码覆盖率,而是为了减少bug出现的概率,以及防止bug回归。因此,面对大型复杂软件系统时


,要掌握有效的方式方法,来提高单元测试的效率和质量,才能避免劳民又伤财。



                                                                         END



如何快速提升软件研发技能,从根本上减少bug出现率,减轻测试负担呢?


6月9-10日,即将于上海举办的K+全球软件研发行业创新峰会,邀请来自BAT、华为、字节跳动


、OPPO、微软、58同城、美团等知名企业的技术专家与会分享。融合主题演讲、互动研讨、案


例分享、实战演练等多种形式,共同探讨软件领域的前沿发展、最佳实践和创新应用。届时,张


逸老师将带来《科技创新推动数字信创建设‍‍》主旨演讲,感兴趣的小伙伴赶快扫码了解详情吧!


返回列表