如何写出具备良好可测试性的代码?

    单元测试在一个完整的软件开发流程中是必不可少的、很是重要的一个环节。一般写单元测试并不难,但有的时候,有的代码和功能难以测试,致使写起测试来困难重重。所以,写出良好的可测试的(testable)代码是很是重要的。接下来,咱们简要地讨论一下什么样的代码是难以测试的,咱们应该如何避免写出难以测试的代码,以及要写出可测试性强的代码的一些最佳实践。php

什么是单元测试(unit test)?

计算机编程中,单元测试英语:Unit Testing)又称为模块测试, 是针对程序模块软件设计的最小单位)来进行正确性检验的测试工做。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。java

  一般一个单元测试主要有三个行为:spring

  1. 初始化须要测试的模块或方法。
  2. 调用方法。
  3. 观察结果(断言)。

    这三个行为分别被称为Arrange, Act and Assert。以java为例,通常测试代码以下:数据库

@Test
    public void isPalindrome() {

        //初始化:初始化须要被测试的模块,这里就是一个对象。
        //也可能没有初始化模块,例如测试一个静态方法。
        PalindromeDetector detector = new PalindromeDetector();

        //调用方法:记录返回值,以便后续验证。
        //若是方法无返回值,那么咱们须要验证它在执行过程当中是否对系统的其余部分形成了影响,或产生了反作用。
        boolean isPalindrome = detector.isPalindrome("kayak");

        //断言:验证返回结果是否和预期一致。
        Assert.assertTrue(isPalindrome);
    }

 

单元测试和集成测试的区别

    单元测试的目的是为了验证颗粒度最小的、独立单元的行为,例如一个方法,一个对象。经过单元测试,咱们能够确保这个系统中的每一个独立单元都正常工做。单元测试的范围仅仅在这个独立单元中,不依赖其余单元。而集成测试的目的是验证整个系统在真实环境下的功能行为,即将不一样模块组合在一块儿进行测试。集成测试一般须要将项目启动起来,而且可能会依赖外部资源,例如数据库,网络,文件等。编程

 

良好的单元测试的特色

1. 代码简洁清晰设计模式

    咱们会针对一个单元写多个测试用例,所以咱们但愿用尽可能简洁的代码覆盖到全部的测试用例。api

2. 可读性强网络

    测试方法的名称应该直截了当地代表测试内容和意图,若是测试失败了,咱们能够简单快速地定位问题。经过良好的单元测试,咱们能够无需经过debug,打断点的方式来修复bug。数据结构

3. 可靠性强框架

    单元测试只在所测的单元中真的有bug才会不经过,不能依赖任何单元外的东西,例如全局变量、环境、配置文件或方法的执行顺序等。当这些东西发生变化时,不会影响测试的结果。

4. 执行速度快

    一般咱们每一次打包都会运行单元测试,若是速度很是慢,影响效率,也会致使更多人在本地跳过测试。

5. 只测试独立单元

    单元测试和集成测试的目的不一样,单元测试应该排除外部因素的影响。

 

如何写出可测试的代码

    咱们从一个简单的例子开始探讨这个问题。咱们正在编写一个智能家居控制器的程序,其中一个需求是在夜晚触摸到台灯时自动开灯。咱们经过如下方法来判断当前时间:

public static String getTimeOfDay() {

    Calendar calendar = GregorianCalendar.getInstance();
    calendar.setTime(new Date());
    int hour = calendar.get(Calendar.HOUR_OF_DAY);

    if (hour >= 0 && hour < 6) {
        return "Night";
    }
    if (hour >= 6 && hour < 12) {
        return "Morning";
    }
    if (hour >= 12 && hour < 18) {
        return "Afternoon";
    }
    return "Evening";
}

以上代码有什么问题呢?若是咱们以单元测试的角度来看,就会发现这段代码根本没法编写测试, new Date() 表明当前时间,这是一个内嵌在方法里的隐含输入,这个输入是随时变化的,不一样时间运行这个方法,返回的值也会不一样。这个方法的不可预测性致使了没法测试。若是要测试,咱们的测试代码可能要这样写:

@Test
public void getTimeOfDayTest() {
    try {
        // 修改系统时间,设为6点
            ...

        String timeOfDay = getTimeOfDay();

        Assert.assertEquals("Morning", timeOfDay);
    } finally {
        // 恢复系统时间
            ...
    }
}

像这样的单元测试违反了许多咱们上述的良好的测试的特色,好比运行测试代价过高(还要改系统时间),不可靠(这个测试有可能由于设置系统时间失败而fail),速度也可能比较慢。其次,这个方法违反了几个原则:

1. 方法和数据源紧耦合在了一块儿

    时间这个输入没法经过其余的数据源获得,例如从文件或者数据库中获取时间。

2. 违反了单一职责原则(Single Responsibility Principle)

    SRP是指每个类或者方法应该有一个单一的功能。而这个方法具备多个职责:1. 从某个数据源获取时间。 2. 判断时间是早上仍是晚上。SRP的一个重要特色是:一个类或者一个模块应该有且只有一个改变的缘由,在上述代码中,却有两个缘由会致使方法的修改:1. 获取时间的方式改变了(例如改为从数据库获取时间)。 2. 判断时间的逻辑改变了(例如把从6点开始算晚上改为从7点开始)。

3. 方法的职责不清晰

    方法签名 String getTimeOfDay() 对方法职责的描述不清晰,用户若是不进入这个api查看源码,很难了解这个api的功能。

4. 难以预测和维护

    这个方法依赖了一个可变的全局状态(系统时间),若是方法中含有多个相似的依赖,那在读这个方法时,就须要查看它依赖的这些环境变量的值,致使咱们很难预测方法的行为。

 

简单改进

public static String GetTimeOfDay(Calendar time) {

    int hour = time.get(Calendar.HOUR_OF_DAY);

    if (hour >= 0 && hour < 6) {
        return "Night";
    }
    if (hour >= 6 && hour < 12) {
        return "Morning";
    }
    if (hour >= 12 && hour < 18) {
        return "Noon";
    }
    return "Evening";
}

如今,这个方法没有了获取时间的职责,他的输出彻底依赖于传递的输入。所以很容易对它进行测试:

@Test
public void getTimeOfDayTest() {

    Calendar time = GregorianCalendar.getInstance();
    //设置时间
    time.set(2018, 10, 1, 06, 00, 00);

    String timeOfDay = GetTimeOfDay(time);

    Assert.assertEquals("Morning", timeOfDay);
}

很好~这个方法具备了可测试性,可是问题依旧没有解决,如今获取时间的职责,转移到了更高层的代码上,即调用这个方法的模块:

public class SmartHomeController {

    private Calendar lastMotionTime;

public void actuateLights(boolean motionDetected) { //更新最后一次触摸的时间 if (motionDetected) { lastMotionTime.setTime(new Date()); } // Ouch! Calendar nowTime = GregorianCalendar.getInstance(); nowTime.setTime(new Date()); //判断时间 String timeOfDay = getTimeOfDay(nowTime); if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) { //晚上触摸台灯,开灯! BackyardLightSwitcher.Instance.TurnOn(); } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 || ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) { //超过一分钟没有触摸,或者白天,关灯! BackyardLightSwitcher.Instance.TurnOff(); } } }

 

要解决这个问题,一般可使用依赖注入(控制反转,IoC),控制反转是一种重要的设计模式,对于单元测试来讲尤为有效。实际工程中,大多数应用都是由多个类经过彼此的合做来实现业务逻辑的,这使得每一个对象都须要得到与其合做的对象(也就是他所依赖的对象)的引用,若是这个获取过程要靠自身实现,那会致使代码高度耦合而且难以测试。那如何反转呢?即把控制权从业务对象手中转交到用户,平台或者框架中。

引入了控制反转后的代码

public class SmartHomeController {

    private Calendar lastMotionTime;
    private Calendar nowTime;

    public SmartHomeController(Calendar nowTime) {
     this.nowTime = nowTime; }   public void actuateLights(boolean motionDetected) { //更新最后一次触摸的时间 if (motionDetected) { lastMotionTime.setTime(new Date()); } //判断时间 String timeOfDay = getTimeOfDay(nowTime); if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) { //晚上触摸台灯,开灯! BackyardLightSwitcher.Instance.TurnOn(); } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 || ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) { //超过一分钟没有触摸,或者白天,关灯! BackyardLightSwitcher.Instance.TurnOff(); } } }

在以前代码中,nowTime的获取是由SmartHomeController本身实现的,引入控制反转后,nowTime是在初始化时由咱们注入到对象中。若是使用spring框架,那注入的工做就由spring框架完成,即控制权转移到了用户或框架手中,这就是控制反转的意思。

 

接下来,咱们就能够在测试中mock时间属性:

@Test
public void testActuateLights() {
    Calendar time = GregorianCalendar.getInstance();
    time.set(2018, 10, 1, 06, 00, 00);

    SmartHomeController controller = new SmartHomeController(time);

    controller.actuateLights(true);

    Assert.assertEquals(time, controller.getLastMotionTime());
}

 到这里,已经能够方便地对其作单元测试了,你认为这段代码已经具备良好的可测试性了吗?

方法的反作用(Side Effects)

咱们仔细看这段开灯关灯的代码:

if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
    //晚上触摸台灯,开灯!
    BackyardLightSwitcher.Instance.TurnOn();
} else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
    ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
    //超过一分钟没有触摸,或者白天,关灯!
    BackyardLightSwitcher.Instance.TurnOff();
}

这里经过控制 BackyardLightSwitcher 这个单例来控制台灯,这是一个全局的变量,意味着每次运行这个单元测试,可能会修改系统中变量的值。换句话说,这个测试产生了反作用。若是有其余的单元测试也依赖了 BackyardLightSwitcher 的值,那么测试的结果就变得不可控了。所以这个方法依旧不具备良好的可测试性。

 

函数式、一等公民

java8中引入了函数式和一等公民的概念。咱们熟悉的对象是数据的抽象,而函数是某种行为的抽象。

头等函数(first-class function)是指在程序设计语言中,函数被看成头等公民。这意味着,函数能够做为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中。 [1] 有人主张应包括支持匿名函数(函数字面量,function literals)。[2]在这样的语言中,函数的名字没有特殊含义,它们被看成具备函数类型的普通的变量对待。

其实咱们能够看到,上述函数依旧不符合单一职责原则,它有两个职责:1. 判断当前时间。 2. 操做台灯。咱们如今将操做台灯的职责从这个方法中移除,做为参数传递进来:

@FunctionalInterface
public interface Action {
    void doAction();
}
public class SmartHomeController {

    private Calendar lastMotionTime;
    private Calendar nowTime;

    public SmartHomeController(Calendar nowTime) {
        this.nowTime = nowTime;
    }

    public void actuateLights(boolean motionDetected, Action turnOn, Action turnOff) {

        //更新最后一次触摸的时间
        if (motionDetected) {
            lastMotionTime.setTime(new Date());
        }

        //判断时间
        String timeOfDay = getTimeOfDay(nowTime);
        if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
            //晚上触摸台灯,开灯!
            turnOn.doAction();
        } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
            ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
            //超过一分钟没有触摸,或者白天,关灯!
            turnOff.doAction();
        }
    }
}

如今,对这个方法作测试,咱们能够将虚拟的行为传递进来:

@Test
public void testActuateLights() {
    Calendar time = GregorianCalendar.getInstance();
    time.set(2018, 10, 1, 06, 00, 00);

    MockLight mockLight = new MockLight();

    SmartHomeController controller = new SmartHomeController(time);

    controller.actuateLights(true, mockLight::turnOn, mockLight::turnOff);

    Assert.assertTrue(mockLight.turnedOn);
}

//用于测试
public class MockLight {

    boolean turnedOn;

    void turnOn() {
        turnedOn = true;
    }

    void turnOff() {
        turnedOn = false;
    }
}

 

如今,咱们真正拥有了一个可测试的方法,它很是稳定、可靠,没必要担忧对系统产生反作用,同时咱们也具备了清晰易懂、可读性强、可重用的api。

在函数式编程中,有一个概念叫纯函数,纯函数的主要特色是:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值之外的其余隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数反作用,诸如“触发事件”,使输出设备输出,或更改输出值之外物件的内容等。

像这样的函数通常具备很是好的可测试性,对它作单元测试方便、且不会出问题,咱们须要作的就只是传参数进去,而后检查返回结果。对于不纯的函数,例如某个函数 Foo() ,它依赖了一个有反作用的函数 Bar() ,那么 Foo() 也变成了一个有反作用的函数,最终,反作用可能会遍及整个系统。

 

 

参考资料:https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters

相关文章
相关标签/搜索