一、必要性
在我们公司中要做单元测试,确实比较难,因为公司缺少这种氛围,有也只是局部的,大多数工程师没有这方面的习惯和素养,很多人都是有一定的抵触的心理,经过我私下的了解大概有以下几种原因吧。
- 写单元测试太耗费时间了,项目要赶进度,编写单元测试会导致不能按时完成开发任务,导致项目延期;
- 做传统xx管理系统的项目,业务逻辑比较简单,主要就是对业务数据做增删改查,单元测试意义和价值不高;
- 公司有专门的测试人员,很多问题在集成测试时一定能发现。
- 以前项目上从没写过单元测试,没有经验,不知道怎么编写单元测试;
这其中对单元测试就有些误解了,单元测试有几个比较常见的典型场景:
- 开发前写单元测试,通过测试描述需求,即测试驱动开发。
- 在开发过程中及时得到反馈,提前规避隐患和发现问题。
- 应用于自动化构建或持续集成流程,对每次代码修改做回归测试。
- 作为重构的基础,验证重构是否可靠。
还有最重要的一点:编写单元测试的难易程度能够直接反应出代码的设计水平,能写出单元测试和写不出单元测试之间体现了编程能力上的巨大的鸿沟。无论是什么样的程序员,坚持编写一段时间的单元测试之后,都会明显感受到代码设计能力的巨大提升。
公司开发人员的代码质量往往不是很高,尤其是对代码的拆分和逻辑的抽象还处于懵懂阶段。要对这类代码写单测,即使是工作了3,4年的高级码农也是一个挑战,对新人来说几乎是不可能完成的任务。这也让很多开发人员有了写单元测试很难的感觉。所以,写单元测试的难易程度跟代码的质量关系最大,并且是决定性的。项目里无论用了哪个测试框架都不能解决代码本身难以测试的问题。
诚然,写单元测试在开发期间的确是会耗费更多时间的,尤其是要追求很高(超过80%
,甚至100%
)的代码覆盖率,更是需要耗费大量心血才能达到的。对于一些只需一次交付,很少维护的项目来说,意义和价值确实不是很大。但这本质上是属于为了赚快钱,不负责任的行为了,毕竟谁都无法保障自己写的程序,真的没有丝毫问题。这个问题的出现并不是个人的问题,而是反映了公司项目管理中的问题。当然,个人的原因也存在,就是如何在有限的时间里,提高效率。
目前公司的大多数项目其实都有着至少两年的维护时间的,很多开发人员都不愿意把自己的时间耗在一个代码很烂、没有单元测试保障且经常变更需求的项目里面。总之,包括我本人在内,都是有项目维护恐惧症的,更愿意投入到新项目的开发中。但是新项目里面还是没有单元测试的保障,代码质量逐渐低劣,如此就又形成了一个不断的循环之中。无法挣脱这个循环的人员就只能选择离职了,也许不慎又到了新的漩涡里面。
一个 bug 被隐藏的时间越长,修复这个 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的教程,总体来说,TestNG 比 Junit4 功能更强大一些,但是相比 Junit5 而言,TestNG 又落后了一代。开源的轮子滚滚向前,都是一代新的轮子超越一代老的轮子。所以,我们这里就只选择 Junit 来作单元测试框架的介绍了吧。
目前最新版本是 JUnit5.2.0,相比 JUnit4 而言有很大的改变,这里主要讲解 JUnit4 的使用(目前的新老项目中应该使用的更多),并对 JUnit5 做简要介绍。学习了 Junit4 的主要使用方式之后,大家再去看JUnit5 用户指南在将来逐渐使用起来更好些。
(1). 简单示例
1 | import static org.junit.Assert.*; |
(2). 注解
@Test
: 测试方法,在这里还可以测试期望异常和超时时间。@Before
: 每个测试方法执行之前执行的方法。@BeforeClass
: 一个测试类中所有测试方法执行之前执行的方法,只执行一次,且方法必须为static
的。@After
: 每个测试方法执行之后执行的方法。@AfterClass
: 一个测试类中所有测试方法执行之后执行的方法,只执行一次,且方法必须为static
的。@Ignore
: 忽略的测试方法。@RunWith
: 指定测试类使用某个运行器。@Parameters
: 参数化测试,指定测试类的测试数据集合。@FixMethodOrder
: 注解在测试类上指定测试方法按一定顺序规则来执行,有三种。
一个测试类单元测试的执行顺序为:
@BeforeClass –> @Before –> @Test –> @After –> @AfterClass
每一个测试方法的执行顺序为:
@Before –> @Test –> @After
综合示例:
1 | import static org.junit.Assert.*; |
如果我们运行上面的测试,控制台输出将是以下几点:
1 | @BeforeClass: onceExecutedBeforeAll |
(3). 断言
断言是编写测试用例的核心实现方式,即期望值是多少,测试的结果是多少,以此来判断测试是否通过。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
注释用来运行套件测试。简单示例如下:
1 | public class TestJunit1 { |
1 | public class TestJunit2 { |
1 | (Suite.class) |
(5). 参数化测试
一个测试类也可以被看作是一个参数化测试类。但它要满足下列所有要求:
- 该类被注解为
@RunWith(Parameterized.class)
。 - 这个类有一个构造函数,存储测试数据。
- 这个类有一个静态方法生成并返回测试数据,并注明
@Parameters
注解。 - 这个类有一个测试,它需要注解
@Test
到方法。
简单示例如下:
1 | import static org.junit.Assert.assertEquals; |
运行CalculateTest
测试用例,控制台输出如下:
1 | Addition with parameters : 1 and 2 |
(6). 忽略测试
有时可能会发生我们的代码还没有准备好的情况,这时测试用例去测试这个方法或代码的时候会造成失败。@Ignore
注释会在这种情况时帮助我们。
- 一个含有
@Ignore
注释的测试方法将不会被执行。 - 如果一个测试类有
@Ignore
注释,则它的测试方法将不会执行
1 | public class JunitTest3 { |
在上面的示例中,JUnit将不会执行testHello()
方法。
(7). 异常测试
它用于测试由方法抛出的异常。
1 | import org.junit.*; |
在上面的示例中,testWithException()
方法将抛出ArithmeticException
异常,因为这是一个预期的异常,因此单元测试会通过。
(8). 超时测试
超时测试是指,一个单元测试运行时间是否超过指定的毫秒数,测试将终止并标记为失败。
1 | import org.junit.*; |
在上面的示例中,testTimeout()
方法将不会返回,因此JUnit引擎会将其标记为失败,并抛出一个异常。java.lang.Exception:test timed out after 1000 milliseconds
。
(9). Hamcrest
在实际开发中,一些基本的断言,如eqaul
, null
, true
它们的可读性并不是很好。而且很多时候我们要比较对象、集合、Map等数据结构。这样我们要么进行大段的字段获取再断言。或者干脆自己编写表达式并断言其结果。JUnit4.4 引入了 Hamcrest 框架,Hamcest 提供了一套匹配符 Matcher,这些匹配符更接近自然语言,可读性高,更加灵活。
Hamcrest提供了大量被称为“匹配器”的方法。其中每个匹配器都设计用于执行特定的比较操作。Hamcrest 的可扩展性很好,让你能够创建自定义的匹配器。最重要的是,JUnit 也包含了 Hamcrest 的核心,提供了对 Hamcrest 的原生支持,可以直接使用 Hamcrest。当然要使用功能齐备的Hamcrest,还是要引入对它的依赖。
看个对比例子,前者使用Junit的 断言,后者使用 Hamcrest 的断言。
1 |
|
1 | // 联合匹配符not和equalTo表示“不等于” |
使用 assertThat 的优点:
- Hamcrest 一条 assertThat 即可以替代其他所有的 assertion 语句,这样可以在所有的单元测试中只使用一个断言方法,使得编写测试用例变得简单,代码风格变得统一,测试代码也更容易维护。
- assertThat 使用了 Hamcrest 的 Matcher 匹配符,用户可以使用匹配符规定的匹配准则精确的指定一些想设定满足的条件,具有很强的易读性,而且使用起来更加灵活
- assertThat 不再像 assertEquals 那样,使用比较难懂的“谓宾主”语法模式(如:
assertEquals(3, x);
),相反,assertThat 使用了类似于“主谓宾”的易读语法模式(如:assertThat(x,is(3));
),使得代码更加直观、易读。 - 可以将这些 Matcher 匹配符联合起来灵活使用,达到更多目的。
JUnit 4.4 自带了一些 Hamcrest 的匹配符 Matcher,但是只有有限的几个,在类org.hamcrest.CoreMatchers
中定义,要想使用他们,必须导入包 org.hamcrest.CoreMatchers.*
。
Hamcrest 提供了很强大的一些api 供我们进行测试断言。
1 | 核心: |
以下示例代码列举了大部分 assertThat 的使用例子,供大家学习使用时参考:
1 | //---------------- 字符相关匹配符 ---------------- |
2. JUnit5
(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). 简单示例
1 | import static org.junit.jupiter.api.Assertions.assertEquals; |
表面上来看,使用方式和 Junit4 差别不大,但是与 JUnit4 比较起来还是有些不同的。
- 导入测试测试注解(
@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 中的一个测试类的基本生命周期示例如下:
1 | "Junit5的测试示例类") ( |
由于 JUnit5 中的新特性很多,限于篇幅就简单介绍到这里了,如想详细了解 Junit5 的更多特性,请前往Junit5官网和JUnit5用户指南中文版去查看。
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会记录所有交互,你可以验证所有你想要验证的东西。
1 | // 静态导入会使代码更简洁 |
Mock一旦创建,模拟对象将记住你的所有的交互。然后,您可以选择性地验证您感兴趣的任何行为。
(2). 如何做一些测试打桩(stubbing)
1 | // 你可以mock具体的类型,不仅只是接口 |
- 默认情况下,所有的函数都有返回值。mock函数默认返回的是null,一个空的集合或者一个被对象类型包装的内置类型,例如
0
、false
对应的对象类型为Integer
、Boolean
; - 测试桩函数可以被覆写: 例如常见的测试桩函数可以用于初始化夹具,但是测试函数能够覆写它。请注意,覆写测试桩函数是一种可能存在潜在问题的做法;
- 一旦测试桩函数被调用,该函数将会一致返回固定的值;
- 上一次调用测试桩函数有时候极为重要,当你调用一个函数很多次时,最后一次调用可能是你所感兴趣的。
(3). 参数匹配器(matchers)
Mockito以自然的java风格来验证参数值: 使用equals()
函数。有时,当需要额外的灵活性时你可能需要使用参数匹配器,也就是argument matchers
:
1 | // 使用内置的anyInt()参数匹配器 |
参数匹配器使验证和测试桩变得更灵活。点击这里可以查看更多内置的匹配器以及自定义参数匹配器或者hamcrest 匹配器的示例。
(4). 验证函数的确切、最少、从未调用次数
1 | // 使用模拟对象 |
verify
函数默认验证的是执行了times(1)
,也就是某个测试函数是否执行了1次.因此,times(1)
通常被省略了。
(5). 为返回值为void的函数通过Stub抛出异常
1 | doThrow(new RuntimeException()).when(mockedList).clear(); |
当你调用doThrow()
, doAnswer()
, doNothing()
, doReturn()
and doCallRealMethod()
这些函数时可以在适当的位置调用when()
函数. 当你需要下面这些功能时这是必须的:
- 测试void函数
- 在受监控的对象上测试函数
- 不知一次的测试为同一个函数,在测试过程中改变mock对象的行为。
但是在调用when()
函数时你可以选择是否调用这些上述这些函数。
(6). 验证执行执行顺序
1 | // A. 验证mock一个对象的函数执行顺序 |
验证执行顺序是非常灵活的。你不需要一个一个的验证所有交互,只需要验证你感兴趣的对象即可。另外,你可以仅通过那些需要验证顺序的mock对象来创建InOrder
对象。
(7). 确保交互(interaction)操作不会执行在mock对象上
1 | // 使用Mock对象 |
(8). 查找冗余的调用
1 | // 使用mock对象 |
一些用户可能会在频繁地使用verifyNoMoreInteractions()
,甚至在每个测试函数中都用。但是verifyNoMoreInteractions()
并不建议在每个测试函数中都使用。verifyNoMoreInteractions()
在交互测试套件中只是一个便利的验证,它的作用是当你需要验证是否存在冗余调用时。滥用它将导致测试代码的可维护性降低。你可以阅读这篇文档来了解更多相关信息。
(9). 简化mock对象的创建
- 最小化重复的创建代码;
- 使测试类的代码可读性更高;
- 使验证错误更易于阅读,因为字段名可用于标识mock对象;
1 | public class ArticleManagerTest { |
注意!下面这句代码需要在运行测试函数之前被调用,一般放到测试类的基类或者test runner中:
1 | MockitoAnnotations.initMocks(testClass); |
关于mock注解的更多信息可以阅读MockitoAnnotations文档。
(10). 为连续的调用做测试打桩 (stub)
有时我们需要为同一个函数调用的不同的返回值或异常做测试桩。
1 | when(mock.someMethod("some arg")) |
另外,连续调用的另一种更简短的版本 :
1 | // 第一次调用时返回"one",第二次返回"two",第三次返回"three" |
(11). 为回调做测试桩
1 | when(mock.someMethod(anyString())).thenAnswer(new Answer() { |
(12). 监控真实对象
你可以为真实对象创建一个监控(spy)对象。当你使用这个spy
对象时真实的对象也会也调用,除非它的函数被stub了。尽量少使用spy对象,使用时也需要小心形式,例如spy对象可以用来处理遗留代码。
1 | List list = new LinkedList(); |
Mockito 并不会为真实对象代理函数调用,实际上它会拷贝真实对象。因此如果你保留了真实对象并且与之交互,不要期望从监控对象得到正确的结果。当你在监控对象上调用一个没有被stub的函数时并不会调用真实对象的对应函数,你不会在真实对象上看到任何效果。
因此结论就是: 当你在监控一个真实对象时,你想在stub这个真实对象的函数,那么就是在自找麻烦。或者你根本不应该验证这些函数。
(13). 重置mocks对象
聪明的 Mockito 使用者很少会用到这个特性,因为他们知道这是出现糟糕测试单元的信号。通常情况下你不会需要重设你的测试单元,只需要为每一个测试方法重新创建一个测试单元就可以了。
如果你真的想通过reset()
方法满足某些需求的话,请考虑实现简单,小而且专注于测试方法而不是冗长,精确的测试。首先可能出现的代码异味就是测试方法中间那的reset()
方法。这可能意味着你已经过度测试了。
添加 reset() 方法的唯一原因就是让它能与容器注入的测试单元协作。
1 | List mock = mock(List.class); |
(14). 更多的注解
@Captor
: 创建ArgumentCaptor
。@Spy
: 可以代替spy(Object)
。@InjectMocks
: 如果此注解声明的变量需要用到mock对象,mockito会自动注入mock或spy成员。
1 | //可以这样写 |
(15). BDD 风格的验证(Since 1.10.0)
开启Behavior Driven Development
(BDD,即行为驱动开发)风格的验证可以通过BBD
的关键词then
开始验证。
1 | given(dog.bark()).willReturn(2); |
以上就是 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包版本一致)。
1 | <dependency> |
(2). 创建测试类
1 | (SpringJUnit4ClassRunner.class) |
- 使用Spring Test 可以使用
@Autowired
自动注入相关的bean信息,而不需要自己手动通过getBean
去获取相应的bean信息。 - 使用Spring Test 测试,可以
@Transaction
注解,表示该方法使用spring的事务,在单元测试中,执行完毕后默认会回滚。 - 使用
@Rollback
注解,标明使用完此方法后事务回滚,可以@Rollback(false)
这个注解来使对数据库操作的测试结果不回滚。
(3). 对 Spring MVC 的测试
为了测试 web 项目,需要一些 Servlet 相关的模拟对象,比如:MockMVC
/MockHttpServletRequest
/MockHttpServletResponse
/MockHttpSession
。使用示例如下:
1 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
注:
demoService
及相关方法的调用,也可以通过Mockito
工具Mock出来,更符合单元测试对单元性的要求,否则这些测试又额外附带了一定集成测试的性质了。
4. spring-boot-starter-test
(1). 简单介绍
现在越来越多的应用都采用SpringBoot的方式来构建,在SpringBoot应用中单元测试变得更加容易了,只需要加入spring-boot-starter-test
的 Starter 即可,其中默认导入了 Spring Boot 测试模块以及JUnit
,AssertJ
,Hamcrest
和其他一些有用的库。
1 | <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). 一个简单示例
1 | (SpringRunner.class) |
上面就是最简单的单元测试写法,测试类上只需要@RunWith(SpringRunner.class)
和@SpringBootTest
两个注解即可测试任何类和方法。
(3). web模块的单元测试
要测试 Spring MVC 控制器是否按预期工作,请使用@WebMvcTest
注释。@WebMvcTest
自动配置Spring MVC
基础结构,并将扫描的bean限制为@Controller
,@ControllerAdvice
,@JsonComponent
,Converter
,GenericConverter
,Filter
,WebMvcConfigurer
和HandlerMethodArgumentResolver
。 使用此注释时,不会扫描常规的@Component
bean。
您还可以使用@AutoConfigureMockMvc
对其进行注释,从而在非@WebMvcTest
(如@SpringBootTest
)中自动配置MockMvc
。 以下示例使用MockMvc:
1 | (SpringRunner.class) |
SpringBoot对各种单元测试的场景支持的比较全,更多的示例可直接在Spiring Boot Test 官方指南中去查看,这里就不再一一列举了。
5. JaCoCo
在做单元测试时,代码覆盖率常常被拿来作为衡量测试好坏的指标,甚至,用代码覆盖率来考核测试任务完成情况,比如,代码覆盖率必须达到80%或 90%。
目前Java常用覆盖率工具clover、Jacoco和Cobertura等。关于这些代码覆盖率工具的对比可参看这里。这里我们就选取 Jacoco 来作为代码覆盖率工具来做介绍。
Jacoco 是一个开源的覆盖率工具。Jacoco 可以嵌入到Ant 、Maven中,并提供了 Eclipse、IDEA 插件,也可以使用Java Agent技术监控Java程序。很多第三方的工具提供了对 Jacoco 的集成,如sonar、Jenkins。
Jacoco与Maven的集成很简单,只需要在plugins中添加如下插件即可。
1 | <plugin> |
做单元测试时,测试覆盖率是不是越高代表代码质量越好呢?Martin Fowler(重构那本书的作者)曾经写过一篇博客来讨论这个问题,他指出:把测试覆盖作为质量目标没有任何意义,而我们应该把它作为一种发现未被测试覆盖的代码的手段。
所以,代码覆盖率统计是用来发现没有被测试覆盖的代码;代码覆盖率统计不能完全用来衡量代码质量。
参考资料