遗留系统改造-那些无法测试的类和方法们

前言

经过前几篇的酝酿,相信大家都已经对编写测试跃跃欲试。

当你兴致勃勃地打开IDE,准确找到需要测试的地方,却发现无从下手。

那些无法测试的遗留代码仿佛在默默地诉说着自己的坚韧不拔,以及顽强不息……

难道我们只能静静地欣赏吗?

哪些情况无法测试?

当在我们一边又一边地欣赏那些顽强的遗留代码时,渐渐发现了那些无法直接测试代码的各种情况:

  • 眼花缭乱的入参
  • 隐藏在内部的依赖
  • 可怕的全局依赖
  • 让人无可奈何的私有方法
  • 那些无法控制的类
  • 无法感知的副作用

下面,我们将逐一击破。

眼花缭乱的入参

我们先来看一个例子:

public class Demo {

    private A a;
    private B b;

    public Demo(A a, B b) {
        this.a = a;
        this.b = b;
    }

    public void say(){
        a.a();

        ...
    }
}

为了测试say方法,我们不得不把所有构建Demo的所有依赖都一一构建。

我们在构建B的时候遇到了一点点问题,因为B又依赖了一堆其他对象。

渐渐地,我们发现事情不对了:要把B构造出来,几乎把整个系统的对象都初始化了一遍。

这个工作量不仅仅异常庞大,有些地方甚至是依赖其他系统的接口。

我们应该如何测试呢?

我们需要解除那些糟糕的依赖。

抛弃没有使用的参数

测试say方法时,我们只使用了A对象,因此完全可以抛弃B对象,没有使用的参数直接传null。

Demo demo = new Demo(new A(), null);
...

接口/实现提取

假设我们需要测试的重点不在于A,而在于之后的方法,使用接口提取或者实现提取后,可以用核心原则-分离-伪装成合作者进行测试。

public class Demo {

    private A a;
    private B b;

    public Demo(A a, B b) {
        this.a = a;
        this.b = b;
    }

    public void say(){
        // 需要分离a方法
        a.a();

        ...
    }
}

改造后

public interface A {
     void a();
}

public class AImpl implements A {
    
    @Overwrited
    public void a() {
        
    }
}

接口提取

根据实现类抽象出一个全新的接口,所有调用的地方都改为新接口。

具体步骤:

  • 创建新接口,暂时不重建方法
  • 目标类实现接口
  • 原来使用目标类的地方改为接口
  • 编译不通过的方法在接口声明

实现提取

将实现类变为一个接口。

具体步骤:

  • 复制一份目标类,名称加后缀,如xxImpl
  • 目标类保留公有方法,并变为接口
  • 产品类(xxImpl)实现该接口
  • 调整原目标类创建方法为新的实现类

隐藏在内部的依赖

我们需要测试的方法中往往隐藏着非常难缠的依赖,我们需要想办法把这些依赖替换掉。

参数化方法/构造函数

把依赖变成一个入参是一个好的方法,它可以快速解决问题。

public void say(){
    A a = new A();
    a.a();

    ...
}

改造后

public void say(A a){
    a.a();

    ...
}

提取并重写工厂方法

另一种方法是将创建依赖对象的过程提取到工厂方法:

  • 找出内部新建依赖对象的逻辑
  • 将所有涉及该对象创建的代码移到一个工厂方法
  • 测试时可以重写工厂方法
public void say(){
    A a = createA()
    a.a();
    ...
}

// 测试时可以重写
protected A createA() {
    return new A();
}

可怕的全局依赖

说到全局依赖,我们很快就能联想到静态方法和单例,对于这些全局方法,我们可以采用核心原则-分离-方法接缝方法,把全局依赖方法独立为单独的成员方法,,测试时可以通过重写实例方法解除依赖。

// 通过重写,我们可以在测试时设置期望的时间戳
protected long now() {
    return System.currentTimeMillis();
}

但假如一个全局依赖在不同的类都被频繁引用,而且这个全局依赖已经成为我们测试的阻碍,如果为每个类都单独抽离大量的成员方法,会让整个类变得奇怪而混乱。

有没有其他方法呢?

静态类

针对静态方法调用,我们可以引入实例委托。

  • 静态方法所属类新增实例方法,保持签名,转发静态方法
  • 原有引用静态方法的地方改为实例方法调用
  • 如果所有静态方法均无直接引用,需要删除,只保留实例方法

权衡得失

一般情况下,工具类的静态方法不会成为你测试的阻碍,往往是一些不合理的设计造成我们需要做出调整。

刚开始的时候,你会对一个类80%是静态方法,只有20%是实例方法感到相当的别扭。
但随着时间的流逝,类下面的静态方法将变得越来越少。

适当保持这种不适感,可以会让你时刻保持对不好代码的关注,促使你持续对它进行改进。

但要记住,别把步子迈得太大_

单例

单例是另一个麻烦的家伙,我们需要想办法把单例对象给替换掉,但不幸的是,单例的设计模式正是为了防止别人改变。

public class SingletonDemo {

    private static SingletonDemo instance = new SingletonDemo();

    // 私有方法防止非法构建
    private SingletonDemo() {
    }

    // 唯一获取内部对象的方法
    public static SingletonDemo getInstance() {
        return instance;
    }

    public void x() {

    }
}

看起来我们已经别无他法,只能做一些取舍。

打破单例约束

因为单例的设计中存在各种各样的限制,为了能够替换单例内部对象,我们必须做一些妥协:

  • 降低单例构造函数的保护级别,可以由伪类继承
  • 设置静态设置方法,替换真实单例
public class SingletonDemo {

    private static SingletonDemo instance = new SingletonDemo();


    // 放宽限制
    protected SingletonDemo() {
    }

    public static SingletonDemo getInstance() {
        return instance;
    }

    public void x() {
    }

    // 开放内部实例设置
    public static void setInstance(SingletonDemo demo) {
        instance = demo;
    }
}

// 伪对象
public class FakeInstance extends SingletonDemo {

    @Override
    public void x() {
    }
}

打破约束后,我们终于可以对单例进行替换。

SingletonDemo.setInstance(new FakeInstance());

权衡得失

你或许会问,我们破坏了单例的安全性只是为了实现可测试性,值得吗?

我认为是值得的,虽然损失了单例的完美性,但是我们得到了更好的测试,而每一个测试都是为系统保驾护航的坚实基础。

你可能还会问,会不会有不明真相的开发人员误用了setInstance导致其他问题?

是有这种可能,但凡是人为的因素,我们都可以通过沟通解决:比如变成我们的代码规范,在团队内部达成一致的观点。

也许我们还可以做得更好,在代码中就可以完成自解释:

public class SingletonDemo {

    /**
    * 注意,此方法仅供测试使用
    **/
    public static void setTestingInstance(SingletonDemo demo) {
        instance = demo;
    }
}

让人无可奈何的私有方法

提到私有方法,也许大家都有一种又爱又恨的感觉,爱其闭合性,也恨其闭合性。

特别是看着那些拥有超多庞大的私有方法,对外却只有一两个公有方法的类,纠结不已。

别灰心,我们有办法对付这些难缠的私有方法。

通过公有方法间接测试

我相信,这个方法肯定是你最常用的手段。

只要设置合理的入参,就可以覆盖公共方法内的各个私有方法,从而达到间接测试私有方法的目的。

但这种方法仅适用于比较简单的类,对于那些庞大的遗留类,你会选择“go die”,或者尝试下面的方法。

设置为公有方法

对,你没有看错,把那些需要测试的private方法直接设置为public,然后你就可以愉快地进行各种测试了。

先放下你的50米大刀,我的朋友。在质疑这个做法前,我们先仔细审视那些私有代码,再问问为什么一定要测试它们?

重新思考后,你可能会发现一次重构代码的好契机。

我们先看一个例子:

public class Scaner {

    // 需要测试的私有方法
    private void sendMail() {
        // 复杂的发送邮件逻辑
    }

    // 扫描文件并发送给指定邮箱
    public void scan(String x) {
        ...  

        sendMail();
    }
}

我们迫切需要测试sendMail方法,因为这个方法非常重要但却隐藏复杂,可它被隐藏在了内部。

或许你已经发现了一些问题:发送邮件这个职责好像不应该属于扫描类,更应该被独立出来。

public class MailSender {

    
    public void send(Mail mail) {
        
    }
}

重构后,我们把发送邮件的逻辑抽离到MailSender,这样一来send改为public方法变得合情合理,同时,原本扫描类的职责也变得更加清晰明了。

public class Demo {

    private MailSender sender;

    public Demo(MailSender sender) {
        this.sender = sender;
    }

    // 扫描文件并发送给指定邮箱
    public void scan(String x) {
        ...  

        sender.send(new Mail(...));
    }
}

至此,我们可以对MailSender进行充分的测试。

所以,尝试将需要测试的私有方法公开化,会让我们更多注重思考如何设计好的代码,让系统持续进化。

提高可见级别

尽管我们已经认识到了独立职责的诸多好处,但在面对庞大的遗留代码时,我们往往没有足够的时间或者信心去做重构。

这时,我们可以选择另一种较为安全、温和的做法:将需要测试的私有方法改为protected或者包可见,快速进行测试。

如果待测试的类难以实例化,但方法逻辑较为独立,我们甚至可以采用更加强硬的方式:直接改造目标方法为protected或者包可见的静态方法。如此一来,测试时便无需实例化类直接进行方法测试。

虽然提高可见级别的方式不是那么漂亮,你甚至觉得这是一个不好的实现,但这恰恰是一个好的开始:它让你强烈意识到这里还存在可以改进的地方。

同时,有了测试这层保护,在未来某个合适的时机,我们可以更加有信心地进行重构,让代码变得越来越漂亮。

使用反射机制

Java的反射功能非常强大,基于此机制的框架也非常多。

相比之前的那些手段,利用反射机制,我们无需修改任何代码,就可以直接对私有方法进行测试。

这种方法简单、方便、快捷,你甚至会对自己能够在最短时间内完成那些讨厌的私有方法的测试感到骄傲。

一切都很美好。

坏的味道

但这种做法往往会带来难以察觉的严重问题:你意识不到原有代码里面的坏味道。

一旦习惯于利用反射进行私有方法的测试,我们就会慢慢安于现状,继续在原有的大类里面添加更多的私有方法,直到整个系统完全腐烂。

这可不是我们想要的结果,系统将慢慢失去了活力,我们也慢慢失去了对更好代码的思考和追求。

所以,不到万不得已,不要采用这种方法。

无法控制的类

Java之所以流行,其中一个原因在于其丰富的第三方类库。依赖这些类库,我们可以快速实现各种复杂的功能。

但有些时候,类库出于某些设计的考量,让我们无法通过继承伪装成合作者:他们可能被final关键字剥脱了扩展的权利。

public final class A {
    public void a(A a){
        // 复杂逻辑
    }
}

public class B {
    public void b(A a){
        // 我们并不想依赖这个方法
        a.a()

        // 这里才是我们需要测试的逻辑
        ...
    }
}

难道我们就无计可施了?

使用基类

对于优秀的类库,作者在设计时就会充分考虑到扩展性和可测试性,他们往往会提供一个接口或者抽象类对具体实现进行解耦。

我们应该优先使用其基类而非实现类。

public final class A implements Parent {
    @Override
    public void a(A a){
        // 复杂逻辑
    }
}

public class B {
    public void b(Parent a){
        a.a()

        // 这里才是我们需要测试的逻辑
        ...
    }
}

接下来,使用伪装者替换进行测试就变得非常简单。

使用包裹类

但现实往往是残酷的,难用的类库比比皆是,我们必须直面惨淡的人生。

/**
* 第三方类库,将内容输出到打印机
**/
public final class SomePrinter {
    public void print(String content) {
        
    }
}

public class Demo {

    public void say(SomePrinter printer) {
        // 通过复杂计算后得出内容,并进行打印
        String content = ...;
        printer.print(content);
    }
}

我们很想感知content的值,但无奈SomePrinter不给我们替换的机会。

面对那些不好的实现,我们没有办法改变其源码,更不用说对其进行接口抽取。

但可以选择将其包裹后,再采用接口抽取的方法创建表现意图的最小方法接口。

/**
* 第三方类库,将内容输出到打印机
**/
public interface Printer {
    public void print(String content) {
        
    }
}

// SomePrinter包裹类
public final class SomePrinterWrapper implements Printer {

    private SomePrinter printer;

    public SomePrinterWrapper(SomePrinter printer) {
        this.printer = printer;
    }

    public void print(String content) {
        printer.print(content);
    }
}

public class Demo {

    // 使用接口入参
    public void say(Printer printer) {
        // 通过复杂计算后得出内容,并进行打印
        String content = ...;
        printer.print(content);
    }
}

接下来,我们可以愉快地伪装成合作者进行测试了。

适用场景

  • 类库的API规模较小
  • 完全分离第三方库依赖
  • 没有现成测试,也无法通过API测试

对于更加复杂的类库API,更好的方法可能是基于职责提取,而不是简单地进行包裹。

无法感知的副作用

在面对遗留代码时,一个方法内部,我们往往会发现大量的不同对象的调用,然后一个对象还会调用另外一个对象,仿佛无穷无尽。

理解原有代码逻辑首要任务,若确定某些调用链你并不关心,我们可以通过之前的手段进行感知和分离。

当我们重构代码的时候,遵循以下原则会让我们的代码更加清晰易懂。

命令查询分离

当我们在编写方法时,确保只有两种类型:查询方法和命令方法。

即一个方法只能用于查询或者只能用于命令。

命令方法不返回查询数据。
查询方法不执行命令操作。

如此分类后,查询方法永远不会改变对象或者数据状态,开发者可以放心使用而无需担心其产生副作用。

这个原则简单却强大,如果我们把这个原则进一步扩展到架构层面,会发生什么样的效果呢?

CQRS(Command Query Responsibility Segregation)也许能够让你大开眼界,这是一个读写分离的全新世界。

结尾

遗留代码总是让人敬畏不已,只有那些测试代码能够让人感到一丝温暖。

我们虽然无法一下改变所有不好的地方,但只要我们坚持每一次修改都做得更好一些,遗留系统慢慢会变得更加有色彩。

我们相信有朝一日,遗留系统终将蜕变。

你可能感兴趣的:(遗留系统改造-那些无法测试的类和方法们)