单元测试一

单元测试

在面向对象语言里,一个方法到一个类,都可以是一个单元,它取决于我们的测试意图。谷歌将单元测试分为小型测试、中型测试和大型测试。

  • 小型测试:针对单个函数的测试,关注其内部逻辑,组件逻辑。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告

  • 中型测试:验证两个或多个制定的模块应用之间的交互

  • 大型测试:也被称为“系统测试”或“端到端测试”。大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。

什么代码适合单元测试?

StackOverflow的讨论"How deep are your unit tests?/单元测试需要多细?"

单元测试一_第1张图片
image.png

老板为我的代码付报酬,而不是测试,所以,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我觉得这种的自信标准应该要高于业内的标准,当然,这种自信也可能是种自大)。如果我的编码生涯中不会犯这种典型的错误(如:在构造函数中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误做测试,所以,我对一些比较复杂的条件逻辑会异常地小心。当在一个团队中,我会非常小心的测试那些会让团队容易出错的代码。

以下将代码情况划分为四个维度:


单元测试一_第2张图片
image.png
  • 依赖很少的简单代码
    对外部依赖很少,代码比较简单,过于简单而没有测试的价值(比如构造方法、get、set方法等)。追求高覆盖率可以进行覆盖,人力吃紧的时候可以选择不进行测试。

  • 依赖较多的简单代码
    代码比较简单,但对外部依赖很多,对于MOCK和HOOK的使用会变多,数据和场景分支伪造的成本很高,不具备有重构的价值,建议不进行测试。

  • 依赖很少的复杂代码
    具备测试的必要性和价值,比较少的外部依赖,比如算法、决策模型等,有着明确输入输出可做校验,非常适合做自动化的模块了

  • 依赖很多的复杂代码
    依赖多自动化成本高,代码复杂又容易出错,可能写测试用例的时间远高于写代码的时间,不做自动化又难以保证质量。对于此种代码,建议进行设计分离以提升可测性

外部依赖和复杂度,是决定单元测试成本高低的重要因素。单独将依赖处理部分解耦出来,仅对剩余的复杂少依赖部分进行测试覆盖。

如何设计可测试的代码?

设计规范

  • SOLID
    单一职责:小而专注
    开闭:对外扩展开放,对内修改关闭
    里式替换:子类替换成父类
    接口隔离:接口小而专注
    依赖倒置:抽象层依赖,细节不依赖

  • 组合优于继承
    继承关键在于利用多态的行为而非重用代码,继承使得一个类只能成为了某个类的子类,父类的修改会导致子类的修改,造成过强的依赖。除非为了多态而继承,否则应该尽量使用组合而不是继承来编写

  • 避免复杂的私有方法
    使方法短小好记,并有助于公共方法更容易阅读,在编写私有方法时,如果觉得有必要测试,应重构代码,提供公共方法使得对外能够提供测试。

  • 避免在方法中直接构建对象
    在方法内直接构建对象,就已经敲定了具体的实现。除非有准确地把握,在方法中创建的对象不需要替换为测试替身,否则,应该使用依赖注入或暴露接口的方式,由外部传入。

常见示例

  • 依赖注入
public class CarEngine {
    public void drive() {
    }
}
public class Car {
    public Car() {
        CarEngine engine = new CarEngine();
    }
}

示例中,直接在构造函数中构建Car的协作者CarEngine对象,使得Car类必须强依赖于CarEngine对象,无法灵活地进行替换。再者,CarEngine的行为可能会有不同的实现逻辑,应将其行为抽象化,提供接口让子类实现具体的逻辑。

public interface Engine {
    void drive();
}
public class SlowEngine implements Engine{
    @Override
    public void drive() {
    }
}
public class Car {
    private Engine engine;
    
    public Car(Engine engine) {
        this.engine = engine;
    }
}

修改后的设计中,Engine将drvie方法提取出来,由不同的子类实现。并且在Car类中提供依赖注入的方式,避免直接构造对象,在单元测试过程中便可自由地进行替换。

  • 构造函数中包含逻辑
public class UUID {
    // 通过mac地址和时间戳计算的结果
    private String value;

    public UUID() {

        // 获取mac地址
        long macAddress = 0;
        Process process = Runtime.getRuntime().exec(new String[]{"ipconfig", "/alll"}, null);
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
        String line = null;
        while (macAddress == 0 && (line = bufferedReader.readLine()) != null) {
            macAddress = extractMacAddressFrom(line);
        }

        
        // 获取时间戳
        long timeMillis = (System.currentTimeMillis() * 10000) + 0x01B21DD213812000L;
        long time = timeMillis << 32;
        time |= (timeMillis & 0xFFFFF00000L) >> 16;
        time |= 0x1000 | ((timeMillis >> 48) & 0x0FFF);

        ......
    }
}

这段代码在构造函数中初始了许多的逻辑,如果要测试这个代码,只能在Windos上实例化这个类,因为它试图执行ipconfig/all,如果我们仍想使用UUID这个类的其他功能,将无法进行测试。

public class UUID {
    private String value;
    public UUID() {
        long macAddress = acquireMacAddress();
        long timeStamp = acquireUuidTimestamp();
        value  = composeUuidStringFrom(macAddress, timeStamp);
    }

    // 获取mac地址
    protected long acquireMacAddress() {.... }
    // 获取时间戳
    protected long acquireUuidTimestamp() {....}
    // 通过mac地址和时间戳计算结果
    private static String composeUuidStringFrom(long macAddress, long timeStamp) {... }

    @Test
    public void test() {
        UUID uuid = new UUID() {
            @Override
            protected long acquireMacAddress() {
                return 0; 
            }

            @Override
            protected long acquireUuidTimestamp() {
                return -1;
            }
        };
    }

将mac地址和时间戳的计算抽取到可以被子类覆盖的protected方法中,在单元测试中我们便可以根据情况,覆盖指定的方法进行测试。

  • 单例的局限
public class Clock {
    private static final Clock singletonInstance = new Clock();

    private Clock() {}
    public static Clock getInstance() {
        return singletonInstance;
    }
}
 public class Log {
     public void log(String message) {
           String prefix = "[" + Clock.getInstance().timeStamp() + "]";
           logFile.write(prefix + message);
     }
 }

单例使得Clock一旦实例化后,就无法被替换,每一处要测试的代码,都是通过静态方法来获取,除非通过反射或提供setter方法来注入。单例由于只存在一个全局对象,在复杂的业务中,不同的逻辑调用,可能使得数据被覆盖和修改的风险,输出非预期的效果,因此,应尽量避免使用单例

  • 无法观察到输出
public class DLog {
    private final DLogTarget[] targets;

    public DLog(DLogTarget... targets) {
        this.targets = targets;
    }

    public void write(Level level, String message) {
        for (DLogTarget dLogTarget : targets) {
            dLogTarget.write(level, message);
        }
    }
}

示例中的write方法返回了void类型,如果在单测中想知道 level 和 message是否被成功地添加进targets中,将变得比较困难,尽管我们可以使用测试替身的方式来解决这个问题,但通常是因为协作者和被测方法紧密相连,而无法用测试替身替换,难以测试

综合案例

下面是一个智能家居控制器的需求,其中一个功能是根据时间,判断白天还是夜晚,达到控制灯光的自动打开或关闭。具体逻辑代码如下:

 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";
  }
public class SmartHomeController {

    private Calendar lastMotionTime;

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

        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();
        }
    }
}

getTimeOfDay方法中的Date对象代表当前时间,这个输入是随时变化的,不同时间运行这个方法,返回的值会不同。这个方法的不可预测性导致了无法测试。在单元测试的时候,我们的测试代码可能这样写:

@Test
public void getTimeOfDayTest() {
    try {
        // 修改系统时间,设为6点
            ...
        String timeOfDay = getTimeOfDay();
        Assert.assertEquals("Morning", timeOfDay);
    } finally {
        // 恢复系统时间
            ...
    }
}

getTimeOfDayTest方法的设计违反了一些原则:

  • 方法和数据源强耦合:即在方法中直接构建了Date对象
  • 违反单一职责原则:方法从某个数据源获取时间,同时又做判断时间是早上还是晚上的逻辑
  • 方法的职责描述不清晰:用户如果不进入这个方法查看源码,很难了解它的功能
  • 难以预测和维护:依赖了一个可变的全局状态(系统时间),如果方法中含有多个类似的依赖,那在读这个方法时,就需要查看它依赖的这些环境变量的值,导致我们很难预测方法的行为。

BackyardLightSwitcher类使用了单例:

  • BackyardLightSwitcher单例设计使得变量为全局类型,如果由其他单元测试也依赖了BackyardLightSwitcher,那么测试结果会变得不可控

改进方案:

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";
 }

这个方法抽离了获取时间数据的职责后,由外部调用输入,并且通过依赖注入的方式,同时我们会将其从业务对象中再转移到上层用户或框架中,如下:

   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();
            }
        }
    }

紧接着我们需要对BackyardLightSwitcher单例设计进行修改,将行为抽取出来,如下:

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;
    }
}

你可能感兴趣的:(单元测试一)