做个人吧,写点Testable代码,好吗!

发布于 2021-10-10 23:58

标题所说的Testable实际上指的是Unit Testable,也就是可单元测试,或者是易书写单元测试的代码。

注:不知道单元测试(Unit Test)是什么的,请自行Google

需求解析


那怎样才算是UnTestable的代码呢?又是什么原因导致很难对其书写单元测试代码呢? 

可以通过一个实际需求来做详细分析,假设我们要实现一个智能灯光控制器,控制器的功能是根据当前所处时间模式,自动将灯光打开或者关闭。其中灯光的模式根据当前系统时间分为以下几种

1. 当时间在 0 ~ 6 点之间,则展示Night模式;
2. 当时间在 6 ~ 12 点之间,则展示Morning模式;
3. 当时间在 12 ~ 18 点之间,则展示Afternoon模式;
4. 其余时间则都默认展示 Evening 模式
上述需求并不复杂,仔细拆分下需求,基本包含以下2部分:
  • 获取当前时间,根据时间返回所对应的主题

  • 调用灯光设置接口(LightSwitcher)相应方法,切换灯光主题

实现代码如下:
public class SmartLightController {

    public void switchLights() {
        String timeOfDay = getTimeOfDay();
        if ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay)) {
            LightSwitcher.getInstance().turnOn();
        } else if ("Morning".equals(timeOfDay) || "Afternoon".equals(timeOfDay)) {
            LightSwitcher.getInstance().turnOff();
        }
    }

    public String getTimeOfDay() {
        // 获取系统时间,并返回相应字符串 Night、Morning等
        int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
        if (hour >= 0 && hour < 6return "Night";
        if (hour >= 
6 && hour < 12return "Morning";
        
if (hour >= 12 && hour < 18return "Afternoon";
        
return "Evening";
    }
}

在上述实现中,getTimeOfDay()方法起着承上启下的作用。正如注释中描述一样,它的作用就是获取当前系统时间,并返回相应主题字符串的方法。

在SmartLightController中主要有2个方法,getTimeOfDay和switchLights。他们的作用依次是返回时间模式和执行开/关灯光操作。因此我们需要对这2个方法执行单元测试,以保证方法的准确性。

问题出在哪?


首先来看getTimeOfDay方法究竟有哪些问题呢?我们可以从2个角度分析这段代码存在的问题。

1. 从单元测试的角度看

如果我们想对这个方法进行单元测试是很困难的。原因在于Calendar.getInstance().get这个接口是跟设备实际情况绑定在一起的。比如我们想测试时间点在8点的时候,getTimeOfDay是否正确的返回"Morning"字符串,那就只能等到早上8点整,准时执行这个方法;或者我们可以在系统设置里手动的将时间设置为早上八点,然后执行此方法。最终单元测试会变成如下形式:
@Test
public void testGetTime_ReturnMorning() {
    // 手动将设备时间修改为早上 6点~12点 之间
    String time = getTimeOfDay();

    assertEquals(time, "Morning");
}
可想而知,如果我们要对每一个时间点都进行单元测试,那会是一项极其耗时又耗力的工作。这严重违反了单元测试快速、独立的原则。

2. 从面向对象开发原则的角度

另一方面,从面向对象开发原则上来看,getTimeOfDay的实现也有待完善。首先它违反了SRP职责单一原则。从职责上来看,getTimeOfDay只需要处理当前时间,然后返回字符串即可。但是在其内部实现中,还承担了调用Calendar接口获取系统当前时间的任务。并且这也间接导致getTimeOfDay方法与设备的系统时间实现有严重的耦合。

代码重构


了解了问题的严重性之后,剩下的只需要将其完善即可。从之前的分析可以看出,似乎所有的问题都出在Calendar.getInstance().get()这行代码中,因为它是所有代码耦合的根源。因此解决办法也就是将其"解耦"。其实解耦方式很简单,我们只需要将其以参数的形式传入getTimeOfDay方法即可,如下所示:
public static String getTimeOfDay(int hour) {
    if (hour >= 0 && hour < 6return "Night";
    if (hour >= 6 && hour < 12return "Morning";
    if (hour >= 12 && hour < 18return "Afternoon";
    return "Evening";
}

通过传入一个int类型的参数,使得getTimeOfDay方法的唯一职责就是判断hour参数,同事不再与系统时间的接口有任何耦合。并且接下来的单元测试代码也会变得非常简单,如下:

@Test
public void testGetTime_For8AM_ReturnMorning() {
    int hour = 8;
    String time = getTimeOfDay(hour);

    assertEquals(time, "Morning");
}
似乎看起来,getTimeOfDay方法到目前为止已经变得Testable了,是不是就万事大吉了呢?很遗憾并没有,刚才我们将getTimeOfDay进行了重构,为其添加了hour参数,这虽然解决了它与Calendar API的耦合;但本质上是将锅甩给了它的调用者,也就是switchLights方法。因此经过修改后的完整代码如下:

很明显,问题并没有根治!如果要对switchLights方法书写单元测试方法,我们还是会遇到同样的问题。

接下来是不是需要像getTimeOfDay方法一样,继续将hour以参数的形式传入进来呢?虽然我们可以这样做,但是这种做法始终不能根治问题,只是将问题一级一级的往上抛而已。

那如何根治这个问题呢?众多解决方案中,有一种非常好的方式就是:依赖注入 Dependency Injection。

依赖注入


上文中也已经提到过,问题的根本在于对于Calendar的耦合,所以需要找一种方式对其进行解偶。在诸如java等面向对象语言中,依赖注入 Dependency Injection 就是一种很好的解偶方式。

 

所谓依赖注入,简单的理解就是如果组件Car依赖组件Engine,并不直接在组件Car中new出组件Engine,取而代之的是在外部创建出组件Engine,然后传递(注入)给组件Car,如下图:

上图来源于谷歌官方网站,具体内容详见: https://developer.android.com/training/dependency-injection

 

接下来,我们需要创建一个提供时间的接口IDateTimeProvider,如下:
public interface IDateTimeProvider {
    int getDateTime();
}

然后通过SmartLightController的构造器,注入IDateTimeProvider接口实例对象。并且在getTimeOfDay方法中,通过接口IDateTimeProvider实例获取时间。如下:

public class SmartLightController {
    private IDateTimeProvider dateTimeProvider;

    public SmartHomeController(IDateTimeProvider dateTimeProvider) {
        // 注入接口实例
        this.dateTimeProvider = dateTimeProvider;
    }

    public void switchLights() {
        // 由DateTimeProvider接口提供当前时间
        int time = dateTimeProvider.getDateTime();
        // ...
    }
}

通过依赖注入的方式,我们将不同IDateTimeProvider的实现类传给SmartLightController类,这样就将SmartLightController和Calendar完全解耦。

单元测试


通过依赖注入实现解耦之后,再加上IDateTimeProvider接口加持,单元测试也变得极为简单。我们可以通过Fake Test Double的方式实现不同模式下的IDateTimeProvider,如下:
class FakeDateTimeProviderReturnMorning implements IDateTimeProvider {
    @Override
    public String getDateTime() {
        return "Morning";
    }
}
class FakeDateTimeProviderReturnAfternoon implements IDateTimeProvider {
    @Override
    public String getDateTime() {
        return "Afternoon";
    }
}
class FakeDateTimeProviderReturnEvening implements IDateTimeProvider {
    @Override
    public String getDateTime() {
        return "Evening";
    }
}
class FakeDateTimeProviderReturnNight implements IDateTimeProvider {
    @Override
    public String getDateTime() {
        return "Night";
    }
}

创建好上述Fake Test Double之后,就可以书写针对性的单元测试代码了,如下:

@Test
public void witchLight_ReturnMorning() {
    IDateTimeProvider dateTimeProvider = new FakeDateTimeProviderReturnMorning();
    SmartLightController sc = new SmartLightController(dateTimeProvider);
    sc.switchLights();
    assertEquals("Morning", sc.getLastLightMotion());
}

@Test
public void witchLight_ReturnAfternoon() {
    IDateTimeProvider dateTimeProvider = new FakeDateTimeProviderReturnAfternoon();
    SmartLightController sc = new SmartLightController(dateTimeProvider);
    sc.switchLights();
    assertEquals("Afternoon", sc.getLastLightMotion());
}


@Test
public void witchLight_ReturnEvening() {
    IDateTimeProvider dateTimeProvider = new FakeDateTimeProviderReturnEvening();
    SmartLightController sc = new SmartLightController(dateTimeProvider);
    sc.switchLights();
    assertEquals("Evening", sc.getLastLightMotion());
}


@Test
public void witchLight_ReturnNight() {
    IDateTimeProvider dateTimeProvider = new FakeDateTimeProviderReturnNight();
    SmartLightController sc = new SmartLightController(dateTimeProvider);
    sc.switchLights();
    assertEquals("Night", sc.getLastLightMotion());
}

:为了测试方便,我在SmartLightController中添加了getLastLightMotion来获取当前保存的灯光模式。

 

总结


单元测试和代码质量是相辅相成的关系,好的代码很容易对其书写单元测试,通过单元测试也能提前预知代码中可能会出现的问题。

如果发现项目中的逻辑代码很难书写单侧,很有可能就是耦合性太高。这样的代码健壮性不高,后期扩展成本也很高。本文介绍了一种很常用的解耦方式:依赖注入。

实际在Android中,还有一种更为高级的依赖注入方式--Dagger。下篇文章将会在这篇文章的基础上,添加Dagger的使用,并介绍如何对Dagger依赖书写单侧代码。

另外,到目前为止SmartLightController实际上还没有完全达到100% Testable的程度,后续篇章也将继续对其做代码重构,使其达到完美状态。

如果你喜欢本文

本文来自网络或网友投稿,如有侵犯您的权益,请发邮件至:aisoutu@outlook.com 我们将第一时间删除。

相关素材