面向测试编程--代码的可测性

1,210 阅读6分钟

这是我参与更文挑战的第26天,活动详情查看: 更文挑战

背景

这是之前参加的一个工程师交流会上别人分享的一个小议题,做了一些笔记,后面整理资料的时候又从网上搜集了一些做补充,今天分享一下

代码的可测性

测试性不好的代码特征

缺陷1: 构造函数做了实际工作

  • 构造函数或域声明中出现new字眼
  • 构造函数或域声明中调用静态方法
  • 构造函数做了分配域字段之外的事情
  • 构造函数中,对象初始化工作没有完成彻底(小心初始化方法)
  • 构造函数中,出现了控制流(基于条件或循环的逻辑)
  • 在构造函数内构造复杂的对象图,而不是使用工厂(factory)模式或构造器(builder)模式
  • 增加或使用初始化块

缺陷2: 滥用协作类

  • 引入了对象,却不直接使用(而是用于通往其它对象)
  • 不遵守迪米特法则:对象图中,方法引用链包含了多个下标点(.)
  • 可疑命名:如context,environment,principal,container或manager

缺陷3: 脆弱的全局变量&单例(Singleton)

  • 增加或使用单例
  • 增加或使用静态域字段或静态方法
  • 增加或使用静态初始化块
  • 增加或使用寄存器
  • 增加或使用服务定位器

缺陷4: 类做事太多

  • 总结该类的作用时,得使用以及之类的描述语
  • 团队新成员很难读懂和快速接手该类
  • 该类的某些域字段只用到部分方法中
  • 该类的某些静态方法仅针对参数操作

构造函数

一个常见的测试性不好的地方就是在构造函数中做了实际工作,在构造函数中执行功能相当于让对象实例化难上加难,或者说给对象模拟增加困难

例1: 构造函数或域声明中出现了new字眼

class House {
    House() {
        kitchen = new Kitchen();
        bedroom = new Bedroom();
    }
}

上面代码的问题:

  • 混杂了对象图的创建与功能逻辑,对象图是指创建不同的实例对象,如何在这些对象之间进行逻辑处理则是独立的
  • 上面的代码或许容易实例化,但如果Kitchen代表的是某类高成本事项,如文件/数据库存取等,那么它就不太易测,因为难以使用模拟对象类替代Kitchen或Bedroom
  • 设计很脆弱,因为不能将House里的kitchen或bedroom的行为以多态的方式进行替换

良好的代码风格是不要在构造函数中创建协作对象,而是将已创建的对象作为参数传递进去

class House {
    House(Kitchen &k, Bedroom &b) {
        kitchen = k;
        bedroom = b;
    }
}

在随后测试时,可以先在外围创建协作对象,然后在创建House对象时,通过参数传入

例2: 构造函数拿到一个部分初始化的对象,而且必须设置它

class Garden {
    Garden(Gardener joe) {
        joe.setWorkday(new TwelveHourWorkday());
        joe.setBoots(new BootsWithMassiveStaticInitBlock());
        this.joe = joe;
    }
}
  • 建立对象(为Garden创建和配置协作对象Gardener)的工作不应当由Garden来做,若配置和实例化混杂在构造函数中,对象就变得更不友好,并且与特定的实体对象图结构绑定,这样就使得代码难以修改,而且不易测
  • Garden需要Gardner,但配置garnder不是Garden的职责
  • Garden的workday在构造函数中被特别设定了,因此迫使让Joe每个workday工作12小时,像这样的强制依赖是不友好的

良好的代码风格是把已经完全初始化的协作对象传进需要的类中

class Garden {
    Garden(Gardener joe) {
       this.joe = joe;
    }
}

例3: 构造函数违反迪米特法则(一个实体要尽可能的只与和它最近的实体进行通讯)

class AccountView {
    User user;
        AccountView() {
        user = RPCClient.getInstance().getUser();
    }
}
  • 上面的代码只需要User对象,但是却访问了全局作用域中的RPCClient的单例(singleton)
  • 上面的代码不仅在构造函数中做了实际工作,破坏了静态方法的无缝性,而且违反了迪米特法则
  • 为了测试上面的代码,就必须创建一个RPCClient实例(静态方法不可避免,也无法模拟),但是被测试类不需要RPCClient,只需要User,但是为了测试,就必须做这些额外的环境布置,导致可测性不好
  • 所有需要构造AccountView类的测试都必须处理这些问题,比如AccountServlet可能需要AccountView,而在测试AccountServlet时,RPCClient实例的预布置工作都要做

良好的代码风格是只传入直接需要的东西,比如协作对象User,这样在测试时,只需要创建真实的或模拟的User对象,这种设计更加灵活而且更具可测性

class AccountView {
    User user;
    AccountView(User user) {
        this.user = user;
    }
}

User getUser(RPCClient rpcClient) {
    return rpcClient.getUser();
}

RPCClient getRPCClient() {
    return new RPCClient();
}

例4: 在构造函数中创建并不需要的第三方对象

class Car {
    Engine engine;
    Car(File file) {
        String model = readEngineModel(file);
        engine = new EngineFactory().create(model);
    }
}
  • 上面代码为了制造自己的引擎engine,要求一辆汽车Car去获取一个引擎工厂EngineFactory,这是难以理解的,汽车得到的应该是造好的引擎,而不是去弄清如何制造引擎
  • 构造函数并不需要直接访问第三方对象,而只是需要第三方对象创建的东西
  • 上面代码在构造函数中创建第三方对象,无法注入也无法覆盖,但却致使代码更加脆弱,因为无法换掉factory对象,也不能试图缓存它,而且一旦新的Car对象被创建,就无法阻止第三方对象的继续运行
  • 所有需要构造Car类的测试都必须处理这个问题,比如有个Garage的测试需要Car,为了顺利调用到Car的构造函数,就不得不去创建新的EngineFactory

良好的代码风格是去掉第三方对象,用简单的变量赋值取代它们在构造函数中的工作,将预先配置好的变量加入到构造函数的成员变量中,让别的对象(factory,builder或Guice Provider)为构造函数参数完成构造工作; 将主体对象从对象图构建职责中解脱出来,可得到一个更灵活,维护性更好的设计

class Car {
    Engien engine;
    Car(Engien engine) {
        this.engine = engine;
    }
}

Engien getEngien(EngineFactory engineFactory, String model) {
    return engineFactory.create(model);
}