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

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

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

通常一个单元测试主要有三个行为:

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

这三个行为分别被称为Arrange, Act and Assert。以java为例,一般测试代码如下:

    @Testpublic void isPalindrome() {//初始化:初始化需要被测试的模块,这里就是一个对象。//也可能没有初始化模块,例如测试一个静态方法。PalindromeDetector detector = new PalindromeDetector();//调用方法:记录返回值,以便后续验证。//如果方法无返回值,那么我们需要验证它在执行过程中是否对系统的其他部分造成了影响,或产生了副作用。boolean isPalindrome = detector.isPalindrome("kayak");//断言:验证返回结果是否和预期一致。Assert.assertTrue(isPalindrome);}

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

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

良好的单元测试的特点

  1. 代码简洁清晰
    我们会针对一个单元写多个测试用例,因此我们希望用尽量简洁的代码覆盖到所有的测试用例。

  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)我们仔细看这段开灯关灯的代码:

这里通过控制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() 也变成了一个有副作用的函数,最终,副作用可能会遍布整个系统。

接耦与单元测试可测性相关推荐

  1. 代码质量与规范,那些年你欠下的技术债

    提到"质量"二字时,我们的第一反应往往是"有多少BUG?""性能好不好?"这样的问题.我们对软件产品或服务的质量定义看其能不能满足用户的需求 ...

  2. 软件测试系列之单元测试 (转载)

    软件测试系列之单元测试   2009-05-26 作者:Delores 来源:Delores的blog   1 基本理论 整理资料时发现以前给兄弟们灌输的单元测试的一些基本知识,放在这里供大家参考.里 ...

  3. C++单元测试学习总结9

    C++单元测试一:并非看上去那么简单--几个很实际的问题 理想与现实 为Java和C#做单元测试,基本上都有比较统一的工具.模式可用,IDE的支持也非常到位:可是到 了C++这里,一切就变的那样的&q ...

  4. Vue.js 单元测试

    单元测试 配置和工具 任何兼容基于模块的构建系统都可以正常使用,但如果你需要一个具体的建议,可以使用 Karma 进行自动化测试.它有很多社区版的插件,包括对 Webpack 和 Browserify ...

  5. Python单元测试最佳实践

    Are you ready? ↓↓↓ 今天的课程为< Python单元测试>,内容共分为三个部分:单元测试的概念.工具与方法.Coverage 统计单元测试覆盖率的工具和Mock 简化单元 ...

  6. 从头到脚说单测——谈有效的单元测试

    导语 非常幸运的是,从4月份至今,我能够全身心投入到腾讯新闻的单元测试专项任务中,从无知懵懂,到不断深入理解的过程,与开发同学互帮互助,受益匪浅.在此过程中,得到了质量总监.新闻总监和乔帮主的倾囊指导 ...

  7. 单元测试:如何编写可测试的代码及其重要性

    原文来自互联网,由长沙DotNET技术社区编译.如译文侵犯您的署名权或版权,请联系小编,小编将在24小时内删除.限于译者的能力有限,个别语句翻译略显生硬,还请见谅. 作者:谢尔盖·科洛迪(SERGEY ...

  8. [转]Angular 单元测试讲解

    Angular_单元测试 测试分类 按开发阶段划分 按是否运行划分 按是否查看源代码划分 其他 ATDD,TDD,BDD,DDD ATDD TDD BDD DDD Angular单元测试 Karma的 ...

  9. Java基础学习总结(118)——单元测试的必要性和重要性

    大部分程序员有两个特点:一不愿意写文档和注释,二不愿意写单测.单元测试是黑盒测试的基础,基本的准入测试,既能验证逻辑的准确性,又能给后续的接口重构提供基础.总之就是『单元测试很重要』,在敏捷迭代开发过 ...

最新文章

  1. 设计模式学习每天一个——Factory模式 和 Abstract Factory模式
  2. Intellij IDEA集成JProfiler性能分析神器
  3. anaconda对应python版本_Python基础——如何查看python版本、如何查看多个python版本
  4. python shape函数_Python中的多态及抽象类
  5. MATLAB用递归法求解集合子集,用递归法求一个集合的子集c语言,急!!!
  6. html属于什么数据类型,javascript包括哪些数据类型?
  7. 在windows XP下如何用Vmware装Linux操作系统
  8. C语言门禁系统单片机,基于单片机的可视对讲门禁系统通信设计
  9. 开机时自动运行shell_病毒究竟是怎么自动执行的(上)?
  10. 面向对象三,约束,加密
  11. 傲腾™,企业应用加速利器!
  12. DeepinXP_V5.8完美/增强精简版2合1
  13. html怎么设置表单的样式,html表单样式 如何用js给html表单设置style
  14. 操作系统 进程通信方式
  15. greasemonkey油猴子初学过程中遇到的问题
  16. 使用promise解决回调地狱_Promise 技术调研 - 回调地狱的产生原因与解决方式
  17. 李沐论文讲解笔记 之 Transformer
  18. AE470 卡通元素动画视频字幕预设手绘歌词文字标题效果制作AE片头
  19. 生成MyEcilpse注册码
  20. java原生开发是什么意思,深入剖析

热门文章

  1. 【bzoj1778】[Usaco2010 Hol]Dotp 驱逐猪猡 矩阵乘法+概率dp+高斯消元
  2. 计算机桌面分区,明基XL2430T如何使用桌面分区?
  3. 【081】Remove-无需注册的在线免费抠图工具
  4. 京东商城总架构师刘海锋:世界上本没有架构,建设的需求多了便有了架构
  5. python项目二:多种验证码及二维码输出
  6. 安装google输入法后,左shift键不能切换中英文
  7. 题解:《你的飞碟在这儿》、《哥德巴赫猜想》
  8. ssd hdd linux分区方案,windows10+ubuntu 16.04+双硬盘(SSD+HDD)分区(图文)
  9. win7安装php失败,win7升win10安装失败怎么办
  10. 关于罗马数字转整数的实现