单元测试
在面向对象语言里,一个方法到一个类,都可以是一个单元,它取决于我们的测试意图。谷歌将单元测试分为小型测试、中型测试和大型测试。
小型测试:针对单个函数的测试,关注其内部逻辑,组件逻辑。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告
中型测试:验证两个或多个制定的模块应用之间的交互
大型测试:也被称为“系统测试”或“端到端测试”。大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。
什么代码适合单元测试?
StackOverflow的讨论"How deep are your unit tests?/单元测试需要多细?"
老板为我的代码付报酬,而不是测试,所以,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我觉得这种的自信标准应该要高于业内的标准,当然,这种自信也可能是种自大)。如果我的编码生涯中不会犯这种典型的错误(如:在构造函数中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误做测试,所以,我对一些比较复杂的条件逻辑会异常地小心。当在一个团队中,我会非常小心的测试那些会让团队容易出错的代码。
以下将代码情况划分为四个维度:
依赖很少的简单代码
对外部依赖很少,代码比较简单,过于简单而没有测试的价值(比如构造方法、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;
}
}