开发人员必备的技能——单元测试

说起软件测试四个字,想必大家脑海中浮现的有集成测试、系统测试、黑盒测试、白盒测试等,可能就是没想到会有单元测试。 对于大学是学习软件工程专业出身的同学来说可能会听过这四个字,对工作好几年的职场老鸟可能也听过但是没实际用过居多。绝大多数的开发人员都是忙于把手头的工作开发好,并不会把单元测试纳入工作范畴,他们会说,我连功能开发都忙不过来了,哪有时间去做单元测试,况且还要写测试代码,那不是重复写一篇代码功能吗?但,单元测试真的不值得花时间去做吗,那是因为可能你并不清楚单元测试的投入产出比有多高,下面就简单介绍单元测试到底能给开发人员带来多少好处。

  • 什么是单元测试

  • 为什么要做单元测试

  • 不写单元测试借口

  • 主流框架 JUnit 和 TestNG

  • Android 中的单元测试

  • 小结

什么是单元测试

单元测试本质上也是代码,与普通代码的区别在于它是验证代码正确性的代码。可简单做个定义:单元测试是开发人员编写的、用于检测在特定条件下目标代码正确性的代码。

软件开发天生就具有复杂性,没人敢打包票说自己写的代码一点问题都没有,或者不经测试就能保证代码正确运行,可能你在这个执行路径下能够执行,殊不知还有其他路径,有一一去验证过吗,因此,要保证程序的正确性就必须要对我们代码进行严格测试。

举个简单例子:比如有个计算类,里面有个 add 方法,操作就是两个数进行相加。

1
2
3
4
5
6
public class Calculator {
public int add(int one, int another) {
//只是简单的两个数相加
return one + another;
}
}

常规做法:假如你写好了这个方法,你想进行验证 add 方法的正确性,需要写个使用 add 方法的 main 函数,首先实例化 Calculator 类,然后调用 add 方法并传入两个参数,比如 1 和 2。然后你运行这个工程,看得出结果是否为 3 ,如果是 3 ,则表明我这个方法写的没有错误,可能就不测试了,就继续开发后续的功能,如果不是 3 ,则返回去看看代码中哪里出错了,重新进行调试,甚至有时候肉眼还看不出代码哪里出错,此时就引入断点去查看,在此期间,很大一部分时间就花在断点、调试、运行上。

单元测试做法:首先会利用 JUnit 测试框架(至于这个框架后面介绍)写一段测试代码,如下:

1
2
3
4
5
6
7
public class CalculatorTest {
public void testAdd() throws Exception {
Calculator calculator = new Calculator();
int sum = calculator.add(1, 2);
Assert.assertEquals(3, sum);
}
}

这里的 CalculatorTest 是 Calculator 对应的测试类,这里的 testAdd 对应着 add 的测试方法,进行测试一般分为三步骤:

  • setup。一般是 new 出你要测试的那个类,比如: Calculator calculator = new Calculator();
  • 执行操作。一般是调用你要测试的那个方法,获得运行结果: int sum = calculator.add(1, 2);
  • 验证结果。验证得到的结果跟预期中是一样的: Assert.assertEquals(3, sum);

看到 Assert 这个关键词了吗,这里可以理解为断言或者期望值,根据入参的值,期望有个什么值输出,而不是靠肉眼去验证是不是自己想要的值,是直接通过判断值是否相等性来验证会具有更客观性。

以上介绍的只是单元测试一点点,那它能给我们带来哪些更多好处呢?

为什么要做单元测试

通常我们在做任何工作会先考虑它的回报,编写代码更是如此。如果单元测试的作用不大,没有人会愿意再写一堆无用的代码,那么单元测试到底能够给我们带来什么优点呢?如下:

  • 便于后期重构。单元测试可以为代码的重构提供保障,只要重构代码之后单元测试全部运行通过,那么在很大程度上表示这次重构没有引入新的BUG,当然这是建立在完整、有效的单元测试覆盖率的基础上。
  • 优化设计。编写单元测试将使用户从调用者的角度观察、思考,特别是使用TDD驱动开发的开发方式,会让使用者把程序设计成易于调用和可测试,并且解除软件中的耦合。
  • 文档记录。单元测试就是一种无价的文档,它是展示函数或类如何使用的最佳文档,这份文档是可编译、可运行的、并且它保持最新,永远与代码同步。
  • 具有回归性。自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地地快速运行测试,而不是将代码部署到设备之后,然后再手动地覆盖各种执行路径,这样的行为效率低下,浪费时间。

等等,讲了这么多优点,无非就是良好的接口设计、正确性、可回归、可测试、完善的调用文档、高内聚、低耦合,这些优点已经足以让我们对单元测试重视起来了,但是个人觉得还有更重要的原因。

  • 首先,带来自信。在接手一个新的项目,或者说是参与一个新的项目开发时,往往这种情况是你半途参加进去的,你需要对已有的代码结构进行解读和理解,对于业务的理解,对于代码个中各个模块关系的理解。如果一开始就理财出错,很可能修改后的代码会引起更多的BUG出现,到那时候又需要修复更多的BUG,改了一个地方,很有可能会莫名其妙地影响另外一个地方,这种现象是很常见的。还有一种情况,假设你修改的功能没问题,但是需要去测试验证,在测试的时候就需要考虑这个功能点它原有的测试路径有哪些,又需要一一去验证功能路径,以证明本次修改对于已存在的功能点不造成影响。这其中就存在着很大的时间成本,导致效率不高。那是否存在着这么一种方式,我需要修改我想改动的地方,不需要关心修改完之后它所造成的影响,也不需要关心它的测试回归性,有,此时就是单元测试登场的时候。写单元测试代码,可以让我自己写的代码足够自信,它是经得起考验的。
  • 其次,更快反馈。对于有一定编程经验的开发人员来说,当他拿到一个新需求的时候,首先想到的不是动手 Coding ,而是会先想想代码的结构,有些类,数据结构该是如何,然后才开始敲代码。如果没有单元测试,一般流程基本是这个模块功能全部写完才开始测试,比如利用 MVP 架构的功能。一般都是开始 Model 模块,然后完善 Presenter 模块,最后写 View 模块,等这几个模块都写完了,再把 APP 跑起来,验证自己写的功能模块是否符合需求,没有符合则继续回去修改代码,这中间需要花费很长的时间才能知道当下自己写的代码是否符合要求,是否正确。那有没有一种即时反馈的方式呢,有,写单元测试即可,当你写完一个函数,马上就匹配一个单元测试函数,这样即写即测的方式可以保证你当场写的代码马上进行修改,测试通过一个,就表示完成一个小的功能点,最后,把函数组装起来,就是我们想要大的功能点。
  • 最后,节约时间。对于 Android 开发来说,一遍一遍的运行 APP ,然后执行相应的用户操作,看界面是否正确的显示,通过这种方式来测试功能,其实是非常浪费时间,而且效率不高,而用单元测试,可以几乎不用打开 APP 来执行,当然有些需要一些资源文件的是需要 APP 运行条件,绝大部分的功能在单元测试阶段就能验证完毕,那么速度就相对快很多。此外,单元测试还能帮忙减少 BUG ,从而减少调试 BUG 的时间,一些低级犯的错误在单元测试阶段就能避免掉。

不写单元测试借口

很多开发人员不写单元测试,最重要的一个原因是他们并不知道单元测试能够带来什么好处,甚至根本不了解单元测试这个词,那自然就像平行线般与之毫无交集。还有一个比较重要的原因是一些开发人员的编程思想还处在一个相对初级的阶段,开发软件只管实现功能,什么高内聚、低耦合、重构、设计、可测试等认为太过专业,对于这些名词以及意义还不了解,这自然不会考虑使用了。还有一些非思想层面的理由,如下:

  • 单元测试太花时间了。软件开发工作那么忙,代码都写不完哪有时间写单元测试。这可能是开发人员用的最多的借口,从某些方面来说,这不能算借口,因为很多开发人员确实在工作上投入的时间特别多。但真的是这样的吗,你有没有想过,导致加班的原因也许就是花了太多时间在手动测试、调试程序上:或许你没有考虑到灵活性与设计,使得在需求发生变更时你需要花很多时间在复杂的代码堆中完成特定的功能,而这些修改又可能引入新的 BUG ,又将导致你需要进行耗时的手动测试、调试等等,如此反复,代码将变得越来越乱,越来越难以维护,最终导致无休止的加班。
  • 测试不是我的工作。测试确实不是开发人员的工作,但单元测试确实是开发人员的工作,测试包含很多种,而只有单元测试是开发人员的工作范畴。开发人员为应用编写代码,那么自然需要保证代码的正确性,而单元测试正是这种保证代码正确性的白盒测试,也就是在了解代码内部结构逻辑的情况下进行有目的的测试,既然说到了解代码,那么开发者自然是最权威的人。因此,编写单元测试并且为测试人员提交正确的代码进行其他测试是开发人员的职责所在。
  • 代码都编译通过了,还测什么。一般来说,这是一个不会放在嘴上但可能藏在心里的借口。代码编译通过只能说你写的代码符合语法要求,并不代表能保证正确性。
  • 代码原来就没有单元测试,并且难以测试。这个问题基本是接受和维护别人开发的代码,而原来的代码本身就没有单元测试了,再加入如果代码的耦合性较高,那么就更难以为这些代码写单元测试。此时正是你了解代码时候,首先为能够测试的部分添加单元测试,保证这些可测试的部分不会被污染,然后在对代码有足够的了解之后再对代码进行重构,降低代码的耦合性,并且慢慢补充测试用例,使得代码的耦合性、可测试性慢慢建立起来。

主流框架 JUnit 和 TestNG

JUnit 是一个 Java 语言的单元测试框架,它是 xUnit 单元测试架构体系的一个实例,用于编写和运行可重复的测试。它包括以下特性:

  • 用于测试期望结果的断言(Assertion)
  • 用于共享共同测试数据的测试工具
  • 用于方便的组织和运行测试的测试套件
  • 图形和文本的测试运行器

TestNG 是一个测试框架,其灵感来自 JUnitNUnit ,但引入了一些新的功能,使其功能更强大,使用更方便。TestNG 消除了大部分的旧框架的限制,使开发人员能够编写更加灵活和强大的测试。 因为它在很大程度上借鉴了Java注解( JDK5.0 引入的)来定义测试,它也可以显示如何使用这个新功能在真实的Java语言生产环境中。

特点如下:

  • 注解
  • TestNG 使用 Java 和面向对象的功能
  • 支持综合类测试(例如,默认情况下,不用创建一个新的测试每个测试方法的类的实例)
  • 独立的编译时测试代码和运行时配置/数据信息
  • 灵活的运行时配置
  • 主要介绍“测试组”。当编译测试,只要要求 TestNG 运行所有的“前端”的测试,或“快”,“慢”,“数据库”等
  • 支持依赖测试方法,并行测试,负载测试,局部故障
  • 灵活的插件 API
  • 支持多线程测试

Android 中的单元测试

因为 JUnit 测试框架是基于 Java 语言,当然 Android 开发也是基于 Java 语言,所以在 Android 中我们可以用 Junit4 单元测试框架进行回归测试,但同时,Google 也提供了一个 AndroidJUnit4 测试框架,看名字就知道它是基于 JUnit 4 框架适合在 Android 环境中做单元测试。

那么,AndroidJUnit4 和 Junit4 有什么区别呢?很大一个区别在于:

1,AndroidJUnit4 测试可以在真机的环境下进行。比如你要测文件读取SD卡,或者操作 SqlLite 数据库,这些条件只有在真机上才有的,此时你用 AndroidJUnit4 框架测试,可以直接跑起来用真实的环境做相应的单元测试。

2,JUnit4 测试是运行在工程项目中,也就是在编译阶段。此时如果想要模拟 Android 环境,比如我想用 JUnit4 来测试 Activity 类,那么就需要引用第三方库来支持,引用 Mockito 和 Robolectric 框架来模拟 Android 环境进行相应的单元测试。

所以何时用 AndroidJUnit4 和 JUnit4 不同的框架进行单元测试,就看你待测试的方法前置条件是什么,然后做不同的选择。

小结

总的来说,单元测试不是集成测试,单元测试只是测试一个方法单元,不是测试一整个流程。集成测试是一种End To End的系统测试,测试相关模块集成在一起是否能够按照预期工作,一般都是接口或者功能层面的测试,可能会依赖很多系统因素,测试的代码逻辑一般比较复杂,运行时间会比较长,出错之后的修复成本高。单元测试则是开发者在集成测试之前就已经进行自测过,同时呢,进行单元测试之后,对于某个方法的执行路径组合进行了一一验证,它只关注三个目标:

  • 有明确的返回值。比如对某个函数进行单元测试,验证其返回值是否符合预期结果。
  • 这个函数只改变其对象内部的一些属性或者状态,函数本身没有返回值,就验证它所改变的属性和状态。
  • 一些函数没有返回值,也没有直接改变哪个值的状态,这就需要验证其行为,比如点击事件。
,