在我们公司中要做单元测试,确实比较难,因为公司缺少这种氛围,有也只是局部的,大多数工程师没有这方面的习惯和素养,很多人都是有一定的抵触的心理,经过我私下的了解大概有以下几种原因吧。
写单元测试太耗费时间了,项目要赶进度,编写单元测试会导致不能按时完成开发任务,导致项目延期;
做传统xx管理系统的项目,业务逻辑比较简单,主要就是对业务数据做增删改查,单元测试意义和价值不高;
公司有专门的测试人员,很多问题在集成测试时一定能发现。
以前项目上从没写过单元测试,没有经验,不知道怎么编写单元测试;
这其中对单元测试就有些误解了,单元测试有几个比较常见的典型场景:
开发前写单元测试,通过测试描述需求,即测试驱动开发。
在开发过程中及时得到反馈,提前规避隐患和发现问题。
应用于自动化构建或持续集成流程,对每次代码修改做回归测试。
作为重构的基础,验证重构是否可靠。
还有最重要的一点:编写单元测试的难易程度能够直接反应出代码的设计水平,能写出单元测试和写不出单元测试之间体现了编程能力上的巨大的鸿沟。无论是什么样的程序员,坚持编写一段时间的单元测试之后,都会明显感受到代码设计能力的巨大提升。
公司开发人员的代码质量往往不是很高,尤其是对代码的拆分和逻辑的抽象还处于懵懂阶段。要对这类代码写单测,即使是工作了3,4年的高级码农也是一个挑战,对新人来说几乎是不可能完成的任务。这也让很多开发人员有了写单元测试很难的感觉。所以,写单元测试的难易程度跟代码的质量关系最大,并且是决定性的。项目里无论用了哪个测试框架都不能解决代码本身难以测试的问题。
诚然,写单元测试在开发期间的确是会耗费更多时间的,尤其是要追求很高(超过80%,甚至100%)的代码覆盖率,更是需要耗费大量心血才能达到的。对于一些只需一次交付,很少维护的项目来说,意义和价值确实不是很大。但这本质上是属于为了赚快钱,不负责任的行为了,毕竟谁都无法保障自己写的程序,真的没有丝毫问题。这个问题的出现并不是个人的问题,而是反映了公司项目管理中的问题。当然,个人的原因也存在,就是如何在有限的时间里,提高效率。
目前公司的大多数项目其实都有着至少两年的维护时间的,很多开发人员都不愿意把自己的时间耗在一个代码很烂、没有单元测试保障且经常变更需求的项目里面。总之,包括我本人在内,都是有项目维护恐惧症的,更愿意投入到新项目的开发中。但是新项目里面还是没有单元测试的保障,代码质量逐渐低劣,如此就又形成了一个不断的循环之中。无法挣脱这个循环的人员就只能选择离职了,也许不慎又到了新的漩涡里面。
一个 bug 被隐藏的时间越长,修复这个 bug 的代价就越大。 |
二、基本概念
单元测试:单元测试又称模块测试,属于白盒测试,是最小单位的测试。模块分为程序模块和功能模块。功能模块指实现了一个完整功能的模块(单元),一个完整的程序单元具备输入、加工和输出三个环节。而且每个程序单元都应该有正规的规格说明,使之对其输入、加工和输出的关系做出名明确的描述。
驱动测试:驱动被测试模块正常运行起来的实体。通俗的说法就是你负责测试模块/方法是中间的,没有main()方法入口,怎么编译,怎么启动呢?就需要写一个带main()的方法来调用你的模块/方法,这个就是驱动测试。
测试桩:代替被测模块调用的子模块的实体,该实体一般为桩函数(stub)。通俗的说法就是你负责测试的模块/方法所调用的模块/方法,所以你需要模仿他们做一个返回值(假的,但符合设计)。
测试覆盖:评测测试过程中已经执行的代码的多少。
测试覆盖率:代码的覆盖程度,一种度量方式。针对代码的测试覆盖率有很多种度量方式,常见的有以下几种:
语句覆盖
判定覆盖
路径覆盖
测试覆盖率数据到底有多大意义。主要有以下几个观点:
路径覆盖率 > 判定覆盖 > 语句覆盖
覆盖率数据只能代表你测试过哪些代码,不能代表你是否测试好这些代码。
不要过于相信覆盖率数据,100%的测试覆盖率并不能保证bug的不出现。
代码覆盖率只是一个最基本的前提,一定要保证,但不是意味着达到指标就代表测试的完成
测试人员不能盲目追求代码覆盖率,而应该想办法设计更多更好的案例,哪怕多设计出来的案例对覆盖率一点影响也没有。
三、单元测试工具
在Java中有非常多的单元测试的工具或框架可供选择,我这里只选择一些常用的、主流的单元测试框架或者工具来作介绍和使用。
JUnit:Java中最有名、使用最广泛的单元测试框架
Mockito:模拟框架,可以让你用干净而简单的API编写测试
Spring Test: 使用 Spring Test 来对Spring相关的项目做单元测试,其中会结合或者集成其他测试框架和工具
spring-boot-starter-test: SpringBoot项目中的单元测试
JaCoCo: 使用离线和运行时字节码工具来收集代码覆盖率指标的框架。
1. JUnit4
JUnit 是使用 Java 语言编写的用于编写和运行可重复的自动化测试的开源测试框架。除了 Junit 之外,TestNg也是Java中非常受欢迎的单元测试框架。两种框架在功能上看起来非常相似,这里有一篇关于JUnit 4 与 TestNG 的对比,总体来说,TestNG 比 Junit4 功能更强大一些,但是相比 Junit5 而言,TestNG 又落后了一代。开源的轮子滚滚向前,都是一代新的轮子超越一代老的轮子。所以,我们这里就只选择 Junit 来作单元测试框架的介绍了吧。
JUnit4 和 TestNG 的功能比较
目前最新版本是 JUnit5.2.0,相比 JUnit4 而言有很大的改变,这里主要讲解 JUnit4 的使用(目前的新老项目中应该使用的更多),并对 JUnit5 做简要介绍。学习了 Junit4 的主要使用方式之后,大家再去看JUnit5 用户指南在将来逐渐使用起来更好些。
(1). 简单示例
import static org.junit.Assert.*; import org.junit.Test; public class CalculateTest { @Test public void testSum() { Calculate calculation = new Calculate(); int sum = calculation.sum(2, 5); int testSum = 7; System.out.println("@Test sum(): " + sum + " = " + testSum); assertEquals(sum, testSum); } } |
@Test: 测试方法,在这里还可以测试期望异常和超时时间。
@Before: 每个测试方法执行之前执行的方法。
@BeforeClass: 一个测试类中所有测试方法执行之前执行的方法,只执行一次,且方法必须为static的。
@After: 每个测试方法执行之后执行的方法。
@AfterClass: 一个测试类中所有测试方法执行之后执行的方法,只执行一次,且方法必须为static的。
@Ignore: 忽略的测试方法。
@RunWith: 指定测试类使用某个运行器。
@Parameters: 参数化测试,指定测试类的测试数据集合。
@FixMethodOrder: 注解在测试类上指定测试方法按一定顺序规则来执行,有三种。
一个测试类单元测试的执行顺序为:
@BeforeClass –> @Before –> @Test –> @After –> @AfterClass
每一个测试方法的执行顺序为:
@Before –> @Test –> @After
综合示例:
import static org.junit.Assert.*; import java.util.*; import org.junit.*; public class AnnotationsTest { private ArrayList testList; @BeforeClass public static void onceExecutedBeforeAll() { System.out.println("@BeforeClass: onceExecutedBeforeAll"); } @Before public void executedBeforeEach() { testList = new ArrayList(); System.out.println("@Before: executedBeforeEach"); } @AfterClass public static void onceExecutedAfterAll() { System.out.println("@AfterClass: onceExecutedAfterAll"); } @After public void executedAfterEach() { testList.clear(); System.out.println("@After: executedAfterEach"); } @Test public void EmptyCollection() { assertTrue(testList.isEmpty()); System.out.println("@Test: EmptyArrayList"); } @Test public void OneItemCollection() { testList.add("oneItem"); assertEquals(1, testList.size()); System.out.println("@Test: OneItemArrayList"); } @Ignore public void executionIgnored() { System.out.println("@Ignore: This execution is ignored"); } } |
@BeforeClass: onceExecutedBeforeAll @Before: executedBeforeEach @Test: EmptyArrayList @After: executedAfterEach @Before: executedBeforeEach @Test: OneItemArrayList @After: executedAfterEach @AfterClass: onceExecutedAfterAll |
断言是编写测试用例的核心实现方式,即期望值是多少,测试的结果是多少,以此来判断测试是否通过。JUnit4.x中的断言核心方法如下:
assertArrayEquals(expecteds, actuals): 查看两个数组是否相等。
assertEquals(expected, actual): 查看两个对象是否相等。类似于字符串比较使用的equals()方法。
assertNotEquals(first, second): 查看两个对象是否不相等。
assertNull(object): 查看对象是否为空。
assertNotNull(object): 查看对象是否不为空。
assertSame(expected, actual): 查看两个对象的引用是否相等。类似于使用“==”比较两个对象。
assertNotSame(unexpected, actual): 查看两个对象的引用是否不相等。类似于使用“!=”比较两个对象。
assertTrue(condition): 查看运行结果是否为true。
assertFalse(condition): 查看运行结果是否为false。
assertThat(actual, matcher): 查看实际值是否满足指定的条件。
fail(): 让测试失败。
(4). 套件测试
测试套件意味着捆绑几个单元测试用例并且一起执行他们。在 JUnit 中,@RunWith和@Suite注释用来运行套件测试。简单示例如下:
public class TestJunit1 { @Test public void testPrint1() { System.out.println("Test Junit 1..."); } } |
public class TestJunit2 { @Test public void testPrint2() { System.out.println("Test Junit 2..."); } } |
@RunWith(Suite.class) @Suite.SuiteClasses({ TestJunit1.class, TestJunit2.class }) public class JunitTestSuite { } |
一个测试类也可以被看作是一个参数化测试类。但它要满足下列所有要求:
该类被注解为@RunWith(Parameterized.class)。
这个类有一个构造函数,存储测试数据。
这个类有一个静态方法生成并返回测试数据,并注明@Parameters注解。
这个类有一个测试,它需要注解@Test到方法。
简单示例如下:
import static org.junit.Assert.assertEquals; import java.util.Arrays; import java.util.Collection; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; @RunWith(Parameterized.class) public class CalculateTest { private int expected; private int first; private int second; public CalculateTest(int expectedResult, int firstNumber, int secondNumber) { this.expected = expectedResult; this.first = firstNumber; this.second = secondNumber; } @Parameters public static Collection addedNumbers() { return Arrays.asList(new Integer[][] { { 3, 1, 2 }, { 5, 2, 3 }, { 7, 3, 4 }, { 9, 4, 5 }, }); } @Test public void sum() { Calculate add = new Calculate(); System.out.println("Addition with parameters : " + first + " and " + second); assertEquals(expected, add.sum(first, second)); } } |
Addition with parameters : 1 and 2 Adding values: 1 + 2 Addition with parameters : 2 and 3 Adding values: 2 + 3 Addition with parameters : 3 and 4 Adding values: 3 + 4 Addition with parameters : 4 and 5 Adding values: 4 + 5 |
(6). 忽略测试
有时可能会发生我们的代码还没有准备好的情况,这时测试用例去测试这个方法或代码的时候会造成失败。@Ignore注释会在这种情况时帮助我们。
一个含有@Ignore注释的测试方法将不会被执行。
如果一个测试类有@Ignore注释,则它的测试方法将不会执行
public class JunitTest3 { @Test @Ignore("该测试方法还没准备好运行.") public void testHello() { System.out.println("Hello World!"); } } |
(7). 异常测试
它用于测试由方法抛出的异常。
import org.junit.*; public class JunitTest4 { @Test(expected = ArithmeticException.class) public void testWithException() { int i = 1 / 0; } } |
(8). 超时测试
超时测试是指,一个单元测试运行时间是否超过指定的毫秒数,测试将终止并标记为失败。
import org.junit.*; public class JunitTest5 { @Test(timeout = 1000) public void testTimeout() { while (true) { // do nothing. } } } |
(9). Hamcrest
在实际开发中,一些基本的断言,如eqaul, null, true它们的可读性并不是很好。而且很多时候我们要比较对象、集合、Map等数据结构。这样我们要么进行大段的字段获取再断言。或者干脆自己编写表达式并断言其结果。JUnit4.4 引入了 Hamcrest 框架,Hamcest 提供了一套匹配符 Matcher,这些匹配符更接近自然语言,可读性高,更加灵活。
Hamcrest提供了大量被称为“匹配器”的方法。其中每个匹配器都设计用于执行特定的比较操作。Hamcrest 的可扩展性很好,让你能够创建自定义的匹配器。最重要的是,JUnit 也包含了 Hamcrest 的核心,提供了对 Hamcrest 的原生支持,可以直接使用 Hamcrest。当然要使用功能齐备的Hamcrest,还是要引入对它的依赖。
看个对比例子,前者使用Junit的 断言,后者使用 Hamcrest 的断言。
@Test public void test_with_junit_assert() { int expected = 51; int actual = 51; assertEquals("failure - They are not same!", expected, actual); } @Test public void test_with_hamcrest_assertThat() { int expected = 51; int actual = 51; assertThat("failure - They are not same!", actual, equalTo(expected)); } |
// 联合匹配符not和equalTo表示“不等于” assertThat( something, not( equalTo( "developer" ) ) ); // 联合匹配符not和containsString表示“不包含子字符串” assertThat( something, not( containsString( "Works" ) ) ); // 联合匹配符anyOf和containsString表示“包含任何一个子字符串” assertThat(something, anyOf(containsString("developer"), containsString("Works"))); |
1.Hamcrest 一条 assertThat 即可以替代其他所有的 assertion 语句,这样可以在所有的单元测试中只使用一个断言方法,使得编写测试用例变得简单,代码风格变得统一,测试代码也更容易维护。
2.assertThat 使用了 Hamcrest 的 Matcher 匹配符,用户可以使用匹配符规定的匹配准则精确的指定一些想设定满足的条件,具有很强的易读性,而且使用起来更加灵活
3.assertThat 不再像 assertEquals 那样,使用比较难懂的“谓宾主”语法模式(如:assertEquals(3, x);),相反,assertThat 使用了类似于“主谓宾”的易读语法模式(如:assertThat(x,is(3));),使得代码更加直观、易读。
4.可以将这些 Matcher 匹配符联合起来灵活使用,达到更多目的。
JUnit 4.4 自带了一些 Hamcrest 的匹配符 Matcher,但是只有有限的几个,在类org.hamcrest.CoreMatchers中定义,要想使用他们,必须导入包 org.hamcrest.CoreMatchers.*。
Hamcrest 提供了很强大的一些api 供我们进行测试断言。
核心:
anything - 总是匹配,如果你不关心测试下的对象是什么是有用的
describedAs - 添加一个定制的失败表述装饰器
is - 改进可读性装饰器 - 见下 “Sugar”
逻辑:
allOf - 如果所有匹配器都匹配才匹配,像Java里的&&
anyOf - 如果任何匹配器匹配就匹配,像Java里的||
not - 如果包装的匹配器不匹配器时匹配,反之亦然
对象:
equalTo - 测试对象相等使用Object.equals方法
hasToString - 测试Object.toString方法
instanceOf, isCompatibleType - 测试类型
notNullValue, nullValue - 测试null
sameInstance - 测试对象实例
Beans:
hasProperty - 测试JavaBeans属性
集合:
array - 测试一个数组元素test an array’s elements against an array of matchers
hasEntry, hasKey, hasValue - 测试一个Map包含一个实体,键或者值
hasItem, hasItems - 测试一个集合包含一个元素
hasItemInArray - 测试一个数组包含一个元素
数字:
closeTo - 测试浮点值接近给定的值
greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo - 测试次序
文本:
equalToIgnoringCase - 测试字符串相等忽略大小写
equalToIgnoringWhiteSpace - 测试字符串忽略空白
containsString, endsWith, startsWith - 测试字符串匹配
以下示例代码列举了大部分 assertThat 的使用例子,供大家学习使用时参考:
//---------------- 字符相关匹配符 ---------------- /**equalTo匹配符断言被测的testedValue等于expectedValue, * equalTo可以断言数值之间,字符串之间和对象之间是否相等,相当于Object的equals方法 */ assertThat(testedValue, equalTo(expectedValue)); /**equalToIgnoringCase匹配符断言被测的字符串testedString *在忽略大小写的情况下等于expectedString */ assertThat(testedString, equalToIgnoringCase(expectedString)); /**equalToIgnoringWhiteSpace匹配符断言被测的字符串testedString *在忽略头尾的任意个空格的情况下等于expectedString, *注意:字符串中的空格不能被忽略 */ assertThat(testedString, equalToIgnoringWhiteSpace(expectedString); /**containsString匹配符断言被测的字符串testedString包含子字符串subString**/ assertThat(testedString, containsString(subString)); /**endsWith匹配符断言被测的字符串testedString以子字符串suffix结尾*/ assertThat(testedString, endsWith(suffix)); /**startsWith匹配符断言被测的字符串testedString以子字符串prefix开始*/ assertThat(testedString, startsWith(prefix)); // ---------------- 一般匹配符 ---------------- /**nullValue()匹配符断言被测object的值为null*/ assertThat(object,nullValue()); /**notNullValue()匹配符断言被测object的值不为null*/ assertThat(object,notNullValue()); /**is匹配符断言被测的object等于后面给出匹配表达式*/ assertThat(testedString, is(equalTo(expectedValue))); /**is匹配符简写应用之一,is(equalTo(x))的简写,断言testedValue等于expectedValue*/ assertThat(testedValue, is(expectedValue)); /**is匹配符简写应用之二,is(instanceOf(SomeClass.class))的简写, *断言testedObject为Cheddar的实例 */ assertThat(testedObject, is(Cheddar.class)); /**not匹配符和is匹配符正好相反,断言被测的object不等于后面给出的object*/ assertThat(testedString, not(expectedString)); /**allOf匹配符断言符合所有条件,相当于“与”(&&)*/ assertThat(testedNumber, allOf(greaterThan(8), lessThan(16))); /**anyOf匹配符断言符合条件之一,相当于“或”(||)*/ assertThat(testedNumber, anyOf(greaterThan(16), lessThan(8))); // ---------------- 数值相关匹配符 ---------------- /**closeTo匹配符断言被测的浮点型数testedDouble在20.0?à0.5范围之内*/ assertThat(testedDouble, closeTo(20.0, 0.5)); /**greaterThan匹配符断言被测的数值testedNumber大于16.0*/ assertThat(testedNumber, greaterThan(16.0)); /** lessThan匹配符断言被测的数值testedNumber小于16.0*/ assertThat(testedNumber, lessThan (16.0)); /** greaterThanOrEqualTo匹配符断言被测的数值testedNumber大于等于16.0*/ assertThat(testedNumber, greaterThanOrEqualTo (16.0)); /** lessThanOrEqualTo匹配符断言被测的testedNumber小于等于16.0*/ assertThat(testedNumber, lessThanOrEqualTo (16.0)); // ---------------- 集合相关匹配符 ---------------- /**hasEntry匹配符断言被测的Map对象mapObject含有一个键值为"key"对应元素值为"value"的Entry项*/ assertThat(mapObject, hasEntry("key", "value")); /**hasItem匹配符表明被测的迭代对象iterableObject含有元素element项则测试通过*/ assertThat(iterableObject, hasItem (element)); /** hasKey匹配符断言被测的Map对象mapObject含有键值“key”*/ assertThat(mapObject, hasKey ("key")); /** hasValue匹配符断言被测的Map对象mapObject含有元素值value*/ assertThat(mapObject, hasValue(value)); |
(1). Junit5简介
JUnit 5 跟以前的JUnit版本不一样,它由几大不同的模块组成,这些模块分别来自三个不同的子项目。
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
JUnit Platform是在JVM上 启动测试框架 的基础平台。它还定义了TestEngine API,该API可用于开发在平台上运行的测试框架。此外,平台还提供了一个从命令行或者 Gradle 和 Maven 插件来启动的 控制台启动器 ,它就好比一个 基于 JUnit4 的 Runner 在平台上运行任何TestEngine。
JUnit Jupiter是一个组合体,它是由在JUnit 5中编写测试和扩展的新 编程模型 和 扩展模型 组成。另外,Jupiter子项目还提供了一个TestEngine,用于在平台上运行基于Jupiter的测试。
JUnit Vintage 提供了一个TestEngine,用于在平台上运行基于JUnit 3和JUnit 4的测试。
JUnit 5需要Java 8(或更高)的运行时环境。不过,你仍然可以测试那些由老版本JDK编译的代码。
(2). 简单示例
import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; class FirstJUnit5Tests { @Test void myFirstTest() { assertEquals(2, 1 + 1); } } |
导入测试测试注解(@Test)和断言方法(assertEquals)的包路径不同。
不需要手动把测试和测试方法声明为public了。
(3). 注解
JUnit Jupiter支持使用下面表格中的注解来配置测试和扩展框架。
所有的核心注解都位于junit-jupiter-api模块的org.junit.jupiter.api`包中。
@Test: 表示该方法是一个测试方法。与JUnit 4的@Test注解不同的是,它没有声明任何属性,因为JUnit Jupiter中的测试扩展是基于它们自己的专用注解来完成的。这样的方法会被继承,除非它们被覆盖。
@ParameterizedTest: 表示该方法是一个参数化测试(可以用不同的参数多次运行试)。这样的方法会被继承,除非它们被覆盖。
@RepeatedTest: 表示该方法是一个重复测试的测试模板(让某个测试方法运行多次)。这样的方法会被继承,除非它们被覆盖。
@TestFactory: 表示该方法是一个动态测试的测试工厂。这样的方法会被继承,除非它们被覆盖。
@TestInstance: 用于配置所标注的测试类的测试实例生命周期。这些注解会被继承。
@TestTemplate: 表示该方法是一个测试模板,它会依据注册的提供者所返回的调用上下文的数量被多次调用。这样的方法会被继承,除非它们被覆盖。
@DisplayName: 为测试类或测试方法声明一个自定义的显示名称(空格、特殊字符甚至是emojis表情)。该注解不能被继承。
@BeforeEach: 表示使用了该注解的方法应该在当前类中每一个使用了@Test、@RepeatedTest、@ParameterizedTest或者@TestFactory注解的方法之前执行;类似于 JUnit4 的@Before。这样的方法会被继承,除非它们被覆盖。
@AfterEach: 表示使用了该注解的方法应该在当前类中每一个使用了@Test、@RepeatedTest、@ParameterizedTest或者@TestFactory注解的方法之后执行;类似于 JUnit4 的@After。这样的方法会被继承,除非它们被覆盖。
@BeforeAll: 表示使用了该注解的方法应该在当前类中所有使用了@Test、@RepeatedTest、@ParameterizedTest或者@TestFactory注解的方法之前执行;类似于 JUnit4 的@BeforeClass。这样的方法会被继承(除非它们被隐藏或覆盖),并且它必须是static方法(除非"per-class" 测试实例生命周期被使用)。
@AfterAll: 表示使用了该注解的方法应该在当前类中所有使用了@Test、@RepeatedTest、@ParameterizedTest或者@TestFactory注解的方法之后执行;类似于 JUnit4 的@AfterClass。这样的方法会被继承(除非它们被隐藏 或覆盖),并且它必须是static方法(除非"per-class" 测试实例生命周期被使用)。
@Nested: 表示使用了该注解的类是一个内嵌、非静态的测试类(让测试编写者能够表示出几组测试用例之间的关系)。@BeforeAll和@AfterAll方法不能直接在@Nested测试类中使用,(除非"per-class"测试实例生命周期被使用)。该注解不能被继承。
@Tag: 用于声明过滤测试的tags,该注解可以用在方法或类上;类似于TesgNG的测试组或 JUnit4 的分类。该注解能被继承,但仅限于类级别,而非方法级别。
@Disable: 用于禁用一个测试类或测试方法;类似于 JUnit4 的@Ignore。该注解不能被继承。
@ExtendWith: 用于注册自定义扩展。该注解不能被继承。
注:被@Test、@TestTemplate、@RepeatedTest、@BeforeAll、@AfterAll、@BeforeEach 或 @AfterEach 注解标注的方法不可以有返回值。
在 JUnit5 中的一个测试类的基本生命周期示例如下:
@BeforeClass: onceExecutedBeforeAll @Before: executedBeforeEach @Test: EmptyArrayList @After: executedAfterEach @Before: executedBeforeEach @Test: OneItemArrayList @After: executedAfterEach @AfterClass: onceExecutedAfterAll请在文本框输入文字 |
3. Mockito
在软件开发中提及Mock,通常理解为模拟对象。为什么需要模拟? 在我们一开始学编程时,我们所写的对象通常都是独立的,并不依赖其他的类,也不会操作别的类。但实际上,软件中是充满依赖关系的,比如我们会基于 service 业务操作类,而 service 类又是基于数据访问类(DAO)的,依次下去,形成复杂的依赖关系。
单元测试的思路就是我们想在不涉及依赖关系的情况下测试代码。这种测试可以让你无视代码的依赖关系去测试代码的有效性。核心思想就是如果代码按设计正常工作,并且依赖关系也正常,那么他们应该会同时工作正常。
有些时候,我们代码所需要的依赖可能尚未开发完成,甚至还不存在,那如何让我们的开发进行下去呢?使用mock可以让开发进行下去,mock技术的目的和作用就是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与测试边界以外的对象隔离开。
我们可以自己编写自定义的 Mock 对象实现 Mock 技术,但是编写自定义的 Mock 对象需要额外的编码工作,同时也可能引入错误。现在实现 Mock 技术的优秀开源框架有很多,Mockito就是一个优秀的用于单元测试的 Mock 框架。
除了Mockito以外,还有一些类似的框架,比如:
EasyMock:早期比较流行的 MocK 测试框架。它提供对接口的模拟,能够通过录制、回放、检查三步来完成大体的测试过程,可以验证方法的调用种类、次数、顺序,可以令 Mock 对象返回指定的值或抛出指定异常。
PowerMock:这个工具是在 EasyMock 和 Mockito 上扩展出来的,目的是为了解决 EasyMock 和 Mockito 不能解决的问题(比如对static, final, private方法均不能 Mock)。其实测试架构设计良好的代码,一般并不需要这些功能,但如果是在已有项目上增加单元测试,老代码有问题且不能改时,就不得不使用这些功能了。
JMockit:JMockit 是一个轻量级的mock框架是用以帮助开发人员编写测试程序的一组工具和API,该项目完全基于Java 5 SE的 java.lang.instrument包开发,内部使用ASM库来修改Java的Bytecode`。
WireMock: 模拟您的API以进行快速、可靠和全面的测试。WireMock是一个基于 HTTP 的 API 的模拟器。有些人可能认为它是一个服务虚拟化工具或模拟服务器。
Mockito 已经被广泛应用,所以这里重点介绍 Mockito,其他的Mock框架也各自有自己的特点,大家下来自己学习或者分享,参考的Mockito中文文档在这里。
下面的例子大多都会模拟一个 List,因为大多数人都熟悉它(比如add(),get(),clear()等方法)。实际上,请不要模拟List类,改用真实的实例。
(1). 验证行为
一旦创建,mock会记录所有交互,你可以验证所有你想要验证的东西。
// 静态导入会使代码更简洁
import static org.mockito.Mockito.*;
// 创建mock对象
List mockedList = mock(List.class);
// 使用mock对象
mockedList.add("one");
mockedList.clear();
// 验证行为
verify(mockedList).add("one");
verify(mockedList).clear();
Mock一旦创建,模拟对象将记住你的所有的交互。然后,您可以选择性地验证您感兴趣的任何行为。
(2). 如何做一些测试打桩(stubbing)
// 你可以mock具体的类型,不仅只是接口
LinkedList mockedList = mock(LinkedList.class);
// 测试桩
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());
// 输出“first”
System.out.println(mockedList.get(0));
// 抛出异常
System.out.println(mockedList.get(1));
// 因为get(999) 没有打桩,因此输出null
System.out.println(mockedList.get(999));
// 验证get(0)被调用的次数
verify(mockedList).get(0);
默认情况下,所有的函数都有返回值。mock函数默认返回的是null,一个空的集合或者一个被对象类型包装的内置类型,例如0、false对应的对象类型为Integer、Boolean;
测试桩函数可以被覆写: 例如常见的测试桩函数可以用于初始化夹具,但是测试函数能够覆写它。请注意,覆写测试桩函数是一种可能存在潜在问题的做法;
一旦测试桩函数被调用,该函数将会一致返回固定的值;
上一次调用测试桩函数有时候极为重要,当你调用一个函数很多次时,最后一次调用可能是你所感兴趣的。
(3). 参数匹配器(matchers)
Mockito以自然的java风格来验证参数值: 使用equals()函数。有时,当需要额外的灵活性时你可能需要使用参数匹配器,也就是argument matchers:
// 使用内置的anyInt()参数匹配器
when(mockedList.get(anyInt())).thenReturn("element");
// 使用自定义的参数匹配器( 在isValid()函数中返回你自己的匹配器实现 )
when(mockedList.contains(argThat(isValid()))).thenReturn("element");
// 输出element
System.out.println(mockedList.get(999));
// 你也可以验证参数匹配器
verify(mockedList).get(anyInt());
参数匹配器使验证和测试桩变得更灵活。点击这里可以查看更多内置的匹配器以及自定义参数匹配器或者hamcrest 匹配器的示例。
(4). 验证函数的确切、最少、从未调用次数
// 使用模拟对象
mockedList.add("once");
mockedList.add("twice");
mockedList.add("twice");
mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");
// 下面的两个验证函数效果一样,因为verify默认验证的就是times(1)
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");
// 验证具体的执行次数
verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");
// 使用never()进行验证,never相当于times(0)
verify(mockedList, never()).add("never happened");
// 使用atLeast()/atMost()
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("five times");
verify(mockedList, atMost(5)).add("three times");
verify函数默认验证的是执行了times(1),也就是某个测试函数是否执行了1次.因此,times(1)通常被省略了。
(5). 为返回值为void的函数通过Stub抛出异常
doThrow(new RuntimeException()).when(mockedList).clear();
// 调用这句代码会抛出异常
mockedList.clear();
当你调用doThrow(), doAnswer(), doNothing(), doReturn() and doCallRealMethod() 这些函数时可以在适当的位置调用when()函数. 当你需要下面这些功能时这是必须的:
测试void函数
在受监控的对象上测试函数
不知一次的测试为同一个函数,在测试过程中改变mock对象的行为。
但是在调用when()函数时你可以选择是否调用这些上述这些函数。
(6). 验证执行执行顺序
// A. 验证mock一个对象的函数执行顺序
// 创建Mock对象
List singleMock = mock(List.class);
// 使用mock对象
singleMock.add("was added first");
singleMock.add("was added second");
// 为该mock对象创建一个inOrder对象
InOrder inOrder = inOrder(singleMock);
// 确保add函数首先执行的是add("was added first"),然后才是add("was added second")
inOrder.verify(singleMock).add("was added first");
inOrder.verify(singleMock).add("was added second");
// B .验证多个mock对象的函数执行顺序
List firstMock = mock(List.class);
List secondMock = mock(List.class);
// 使用mock对象
firstMock.add("was called first");
secondMock.add("was called second");
// 为这两个Mock对象创建inOrder对象
InOrder inOrder = inOrder(firstMock, secondMock);
// 验证它们的执行顺序
inOrder.verify(firstMock).add("was called first");
inOrder.verify(secondMock).add("was called second");
验证执行顺序是非常灵活的。你不需要一个一个的验证所有交互,只需要验证你感兴趣的对象即可。另外,你可以仅通过那些需要验证顺序的mock对象来创建InOrder对象。
(7). 确保交互(interaction)操作不会执行在mock对象上
// 使用Mock对象
mockOne.add("one");
// 普通验证
verify(mockOne).add("one");
// 验证某个交互是否从未被执行
verify(mockOne, never()).add("two");
// 验证mock对象没有交互过
verifyZeroInteractions(mockTwo, mockThree);
(8). 查找冗余的调用
// 使用mock对象
mockedList.add("one");
mockedList.add("two");
verify(mockedList).add("one");
// 下面的验证将会失败
verifyNoMoreInteractions(mockedList);
一些用户可能会在频繁地使用verifyNoMoreInteractions(),甚至在每个测试函数中都用。但是verifyNoMoreInteractions()并不建议在每个测试函数中都使用。verifyNoMoreInteractions()在交互测试套件中只是一个便利的验证,它的作用是当你需要验证是否存在冗余调用时。滥用它将导致测试代码的可维护性降低。你可以阅读这篇文档来了解更多相关信息。
(9). 简化mock对象的创建
最小化重复的创建代码;
使测试类的代码可读性更高;
使验证错误更易于阅读,因为字段名可用于标识mock对象;
public class ArticleManagerTest {
@Mock private ArticleCalculator calculator;
@Mock private ArticleDatabase database;
@Mock private UserProvider userProvider;
private ArticleManager manager;
注意!下面这句代码需要在运行测试函数之前被调用,一般放到测试类的基类或者test runner中:
MockitoAnnotations.initMocks(testClass);
关于mock注解的更多信息可以阅读MockitoAnnotations文档。
(10). 为连续的调用做测试打桩 (stub)
有时我们需要为同一个函数调用的不同的返回值或异常做测试桩。
when(mock.someMethod("some arg"))
.thenThrow(new RuntimeException())
.thenReturn("foo");
// 第一次调用 : 抛出运行时异常
mock.someMethod("some arg");
// 第二次调用 : 输出"foo"
System.out.println(mock.someMethod("some arg"));
// 后续调用 : 也是输出"foo"
System.out.println(mock.someMethod("some arg"));
另外,连续调用的另一种更简短的版本 :
// 第一次调用时返回"one",第二次返回"two",第三次返回"three"
when(mock.someMethod("some arg"))
.thenReturn("one", "two", "three");
(11). 为回调做测试桩
when(mock.someMethod(anyString())).thenAnswer(new Answer() {
Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
Object mock = invocation.getMock();
return "called with arguments: " + args;
}
});
// 输出 : "called with arguments: foo"
System.out.println(mock.someMethod("foo"));
(12). 监控真实对象
你可以为真实对象创建一个监控(spy)对象。当你使用这个spy对象时真实的对象也会也调用,除非它的函数被stub了。尽量少使用spy对象,使用时也需要小心形式,例如spy对象可以用来处理遗留代码。
List list = new LinkedList();
List spy = spy(list);
// 你可以为某些函数打桩
when(spy.size()).thenReturn(100);
// 通过spy对象调用真实对象的函数
spy.add("one");
spy.add("two");
// 输出第一个元素
System.out.println(spy.get(0));
// 因为size()函数被打桩了,因此这里返回的是100
System.out.println(spy.size());
// 交互验证
verify(spy).add("one");
verify(spy).add("two");
Mockito 并不会为真实对象代理函数调用,实际上它会拷贝真实对象。因此如果你保留了真实对象并且与之交互,不要期望从监控对象得到正确的结果。当你在监控对象上调用一个没有被stub的函数时并不会调用真实对象的对应函数,你不会在真实对象上看到任何效果。
因此结论就是: 当你在监控一个真实对象时,你想在stub这个真实对象的函数,那么就是在自找麻烦。或者你根本不应该验证这些函数。
(13). 重置mocks对象
聪明的 Mockito 使用者很少会用到这个特性,因为他们知道这是出现糟糕测试单元的信号。通常情况下你不会需要重设你的测试单元,只需要为每一个测试方法重新创建一个测试单元就可以了。
如果你真的想通过reset()方法满足某些需求的话,请考虑实现简单,小而且专注于测试方法而不是冗长,精确的测试。首先可能出现的代码异味就是测试方法中间那的reset()方法。这可能意味着你已经过度测试了。
添加 reset() 方法的唯一原因就是让它能与容器注入的测试单元协作。
List mock = mock(List.class);
when(mock.size()).thenReturn(10);
mock.add(1);
reset(mock);
//at this point the mock forgot any interactions & stubbing
(14). 更多的注解
@Captor: 创建ArgumentCaptor。
@Spy: 可以代替spy(Object)。
@InjectMocks: 如果此注解声明的变量需要用到mock对象,mockito会自动注入mock或spy成员。
//可以这样写
@Spy
BeerDrinker drinker = new BeerDrinker();
//也可以这样写,mockito会自动实例化drinker.
@Spy
BeerDrinker drinker;
//会自动实例化LocalPub
@InjectMocks
LocalPub pub;
(15). BDD 风格的验证(Since 1.10.0)
开启Behavior Driven Development(BDD,即行为驱动开发)风格的验证可以通过BBD的关键词then开始验证。
given(dog.bark()).willReturn(2);
// when
...
then(person).should(times(2)).ride(bike);
以上就是 Mockito 的主要使用方式,关于更详细的介绍可参考Mockito官方文档和Mockito中文文档。
4. Spring Test
目前几乎大多数 Java web 项目都是有基于 Spring 来开发的。通过 Spring 进行 bean 管理后,仅仅通过 JUnit 来做测试会有各种麻烦,比如:Spring容器初始化问题、使用硬编码方式手工获取Bean、不方便对数据操作的正确性做检查等。这时我们就可以通过 Spring 全家桶中的另一位成员spring-test来帮助我们在 Spring 工程中做单元测试了。以下通过简单的示例来演示其使用。
(1). 加入依赖包
通过Maven加入JUnit、spring-test的Jar包(最好其他Spring包版本一致)。
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>xxxx</version> <scope>test</scope> </dependency> |
(2). 创建测试类
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration("/application-context-test.xml") public class UserDaoTest { /** 自动注入baseDao,默认按名称. */ @Resource private IBaseDao baseDao; @Test @Transactional @Rollback public void insert() { String sql = "INSERT INTO t_user(c_name, c_password) values(?, ?)"; Object[] objs = new Object[]{"zhangsan", "123456"}; baseDao.insert(sql , objs); String sql2 = "SELECT * FROM t_user WHERE c_name = ? and c_password = ?"; List<Map<String,Object>> list = baseDao.queryForList(sql1, objs); assertTrue(list.size() > 0); System.out.println(list); } } |
使用Spring Test 测试,可以@Transaction注解,表示该方法使用spring的事务,在单元测试中,执行完毕后默认会回滚。
使用@Rollback注解,标明使用完此方法后事务回滚,可以@Rollback(false)这个注解来使对数据库操作的测试结果不回滚。
(3). 对 Spring MVC 的测试
为了测试 web 项目,需要一些 Servlet 相关的模拟对象,比如:MockMVC/MockHttpServletRequest/MockHttpServletResponse/MockHttpSession。使用示例如下:
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration("/application-context-test.xml") @WebAppConfiguration("src/main/resources") // 此注解指定web资源的位置,默认为src/main/webapp public class TestControllerIntegrationTests { private MockMvc mockMvc; // 模拟MVC对象 @Autowired private DemoService demoService;// 在测试用例注入spring的bean @Autowired WebApplicationContext wac; // 注入WebApplicationContext @Autowired MockHttpSession session; // 注入模拟的http session @Autowired MockHttpServletRequest request; // 模拟request @Before // 测试开始前的初始化工作 public void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); //2 } @Test public void testNormalController() throws Exception{ String exp_str = demoService.saySomething(); // expect str mockMvc.perform(get("/normal")) // 模拟GET /normal .andExpect(status().isOk())// 预期返回状态为200 .andExpect(view().name("page"))// 预期view的名称 .andExpect(forwardedUrl("/WEB-INF/classes/views/page.jsp"))// 预期页面转向的真正路径 .andExpect(model().attribute("msg", exp_str));// 预期model里的值 } @Test public void testRestController() throws Exception{ mockMvc.perform(get("/testRest")) // HTTP GET 方法 .andExpect(status().isOk()) .andExpect(content().contentType("text/plain;charset=UTF-8"))//14 .andExpect(content().string(demoService.saySomething()));//15 } } |
4. spring-boot-starter-test
(1). 简单介绍
现在越来越多的应用都采用SpringBoot的方式来构建,在SpringBoot应用中单元测试变得更加容易了,只需要加入spring-boot-starter-test的 Starter 即可,其中默认导入了 Spring Boot 测试模块以及JUnit,AssertJ,Hamcrest和其他一些有用的库。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> |
spring-boot-starter-test的 Starter (Scope为test),包括了以下提供的类库:
JUnit:单元测试Java应用程序的事实标准。
Spring Test 和 Spring Boot Test:Spring Boot应用程序的实用程序和集成测试支持。
AssertJ:流畅的断言库。
Hamcrest:匹配器对象库。
Mockito:Java Mock 框架。
JSONassert:JSON的断言库。
JsonPath:JSON的XPath。
我们通常在编写测试时发现这些通用库都是比较有用的。如果这些库还不适合您的需求,您还可以添加您自己的附加测试依赖库。
Spring Boot 提供了一个@SpringBootTest注释,当您需要 Spring Boot 功能时,它可以用作标准 spring-test @ContextConfiguration注释的替代方法。注解的工作原理是通过SpringApplication创建用于测试的ApplicationContext。除了@SpringBootTest之外,还提供了许多其他注释来测试应用程序的更具体的切片。
提示:不要忘记在测试中添加@RunWith(SpringRunner.class),否则注释将被忽略。
(2). 一个简单示例
@RunWith(SpringRunner.class) @SpringBootTest public class UserServiceTest { @Value("${msg}") private String msg; @Autowired private UserService userService; @Test public void getUser() { User user = userService.selectByKey(20180302325L); Assert.assertThat(user.getName(), is("Blinkfox")); System.out.println("获取的配置信息为:" + msg); } } |
(3). web模块的单元测试
要测试 Spring MVC 控制器是否按预期工作,请使用@WebMvcTest注释。@WebMvcTest自动配置Spring MVC基础结构,并将扫描的bean限制为@Controller,@ControllerAdvice,@JsonComponent,Converter,GenericConverter,Filter,WebMvcConfigurer和HandlerMethodArgumentResolver。 使用此注释时,不会扫描常规的@Component bean。
您还可以使用@AutoConfigureMockMvc对其进行注释,从而在非@WebMvcTest(如@SpringBootTest)中自动配置MockMvc。 以下示例使用MockMvc:
@RunWith(SpringRunner.class) @WebMvcTest(UserVehicleController.class) public class MyControllerTests { @Autowired private MockMvc mvc; @MockBean private UserVehicleService userVehicleService; @Test public void testExample() throws Exception { given(this.userVehicleService.getVehicleDetails("sboot")) .willReturn(new VehicleDetails("Honda", "Civic")); this.mvc.perform(get("/sboot/vehicle").accept(MediaType.TEXT_PLAIN)) .andExpect(status().isOk()).andExpect(content().string("Honda Civic")); } } |
5. JaCoCo
在做单元测试时,代码覆盖率常常被拿来作为衡量测试好坏的指标,甚至,用代码覆盖率来考核测试任务完成情况,比如,代码覆盖率必须达到80%或 90%。 目前Java常用覆盖率工具clover、Jacoco和Cobertura等。关于这些代码覆盖率工具的对比可参看这里。这里我们就选取 Jacoco 来作为代码覆盖率工具来做介绍。
Jacoco 是一个开源的覆盖率工具。Jacoco 可以嵌入到Ant 、Maven中,并提供了 Eclipse、IDEA 插件,也可以使用Java Agent技术监控Java程序。很多第三方的工具提供了对 Jacoco 的集成,如sonar、Jenkins。
Jacoco与Maven的集成很简单,只需要在plugins中添加如下插件即可。
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.7.7.201606060606</version> <configuration> <destFile>target/coverage-reports/jacoco-unit.exec</destFile> <dataFile>target/coverage-reports/jacoco-unit.exec</dataFile> </configuration> <executions> <execution> <id>jacoco-initialize</id> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>jacoco-site</id> <phase>package</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> |
所以,代码覆盖率统计是用来发现没有被测试覆盖的代码;代码覆盖率统计不能完全用来衡量代码质量。