使用JUnit5,Mockito,Hamcrest进行单元测试
🏏

使用JUnit5,Mockito,Hamcrest进行单元测试

Tags
Junit
TDD
Test
Published
March 7, 2018
Author
Eironn Walker

单元测试遇到的困境

一个常见的例子
@RunWith(SpringRunner.class) @SpringBootTest(classes = Application.class) @Transactional @Rollback(true) // 事务自动回滚,默认是true。可以不写 public class HelloServiceTest { @Autowired private HelloService helloService; @Test public void sayHello() { helloService.sayHello("zhangsan"); } }
  1. 执行一次单元测试为何耗时很久?
    1. Spring的加载
      上述代码中@RunWith(SpringRunner.class)生命在Spring环境中进行单元测试,以及使用了Spring的注解@Autowired,这导致了Spring的加载。
      整个项目的启动
      @SpringBootTest启动了SpringBoot环境,它会扫描应用程序的spring配置,并构建完整的Spring Context。而classes = Application.class启动了整个项目
  1. 如何写一个健壮优雅的单元测试?
    1. 健壮的单元测试是:
      每次执行通过的单测都是成功的,除非改动了单测或者业务。
      每次执行失败的单测都是失败的,除非改动了单测或者业务。
      优雅的单测:
      以来assert来判断业务的成功与否。
      尽量多的考虑实际错误的场景。
  1. 是否要测试数据库层?
    1. 以下是个人观点,有的人认为完全不需要,要杜绝此类测试,有的人认为需要,也有人认为皆可。
      我认同根据业务来选择是否测试DAO层。
      一般的业务实现,没有复杂sql的实际是不需要测试DAO层的,完全通过mock数据的方式来验证业务的可行性。
      对于含有复杂查询的sql,可以执行单独的测试方法来验证sql的正确性。

JUnit5介绍

Junit5是Java单元测试的最新标准,这个最新标准支持了很多Java8的写法,包含lambda等。

一.JUnit5和之前版本的一些区别

  • JUnit5现在放在org.junit.jupiter包下。
  • JUnit4适配于最低JDK5,而JUnit5适配于最低JDK8.
  • JUnit4的@Before, @BeforeClass, @After, 和 @AfterClass被JUnit5中的@BeforeEach, @BeforeAll @AfterEach, and @AfterAll替代。
  • JUnit4的@Ignore注解被@Disabled注解替代。
  • @Category注解被@Tag替代。
  • JUnit5增加了一些新的断言。
  • Runners 已被扩展替换,为扩展实现者提供了新的 API。
  • JUnit5新增了阻止测试执行的断言。
  • JUnit5支持内嵌以及动态测试类。

二.JUnit5的核心依赖

  • junit-jupiter-api 定义了写测试方法的API以及一些扩展
  • junit-jupiter-engine 是执行单元测试的实现引擎
  • junit-jupiter-params 提供单元测试参数化相关功能
除了依赖核心依赖外,我们还需要maven-surefire-plugin 插件,下面是完整的示例。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.javaworld.geekcap</groupId> <artifactId>junit5</artifactId> <packaging>jar</packaging> <version>1.0-SNAPSHOT</version> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>8</source> <target>8</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.0.0-M4</version> </plugin> </plugins> </build> <name>junit5</name> <url>http://maven.apache.org</url> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.6.0</version> <scope>test</scope> </dependency> </dependencies> </project>

三.使用JUnit5的一个示例

通过一个简单的示例来介绍JUnit5,下面代码中创建了一个MathTools 来把一个分子和分母转换为double。
Listing 1. An example JUnit 5 project (MathTools.java)
package com.javaworld.geekcap.math; public class MathTools { public static double convertToDecimal(int numerator, int denominator) { if (denominator == 0) { throw new IllegalArgumentException("Denominator must not be 0"); } return (double)numerator / (double)denominator; } }
我们对两个点进行测试:
  • 合法测试,给一个非0的分母。
  • 一个非法测试,给定一个0的分母。
Listing 2. A JUnit 5 test class (MathToolsTest.java)
package com.javaworld.geekcap.math; import java.lang.IllegalArgumentException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class MathToolsTest { @Test void testConvertToDecimalSuccess() { double result = MathTools.convertToDecimal(3, 4); Assertions.assertEquals(0.75, result); } @Test void testConvertToDecimalInvalidDenominator() { Assertions.assertThrows(IllegalArgumentException.class, () -> MathTools.convertToDecimal(3, 0)); } }
常用的断言方法
  • assertArrayEquals 将实际数组的内容与预期数组的内容进行比较。
  • assertEquals 将实际值与预期值进行比较。
  • assertNotEquals 比较两个值以验证它们不相等。
  • assertTrue 验证提供的值是否为True。
  • assertFalse 验证提供的值是否为False。
  • assertLinesMatch 比较两个集合的字符串。
  • assertNull 验证给定的值是null。
  • assertNotNull 验证给定的值不是null。
  • assertSame 验证两个值引用相同。
  • assertNotSame 验证两个值引用不同。
  • assertThrows 验证执行的方法抛出了指定的异常。
  • assertTimeout 验证执行的方法在指定的超时时间内完成。
  • assertTimeoutPreemptively 验证执行的方法在指定的超时时间内完成,但是如果没有完成就会停止。
分析增强单元测试的结果
assert方法允许我们添加一段描述,来说明执行失败后的错误原因,以帮助我们排查问题。
Assertions.assertEquals(0.75, result, "The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4"); Assertions.assertEquals(0.75, result, () -> "The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4");
我们还可以增加一个@DisplayName 注解来说明这个单元测试的作用,例如:
@Test @DisplayName("Test successful decimal conversion") void testConvertToDecimalSuccess() { double result = MathTools.convertToDecimal(3, 4); Assertions.assertEquals(0.751, result); }

使用JUnit5的参数化功能

还是利用MathTools 中新增一个isEven 方法
public static boolean isEven(int number) { return number % 2 == 0; }
我们可以按照之前的方法来测试这个方法:
@Test void testIsEvenSuccessful() { Assertions.assertTrue(MathTools.isEven(2)); Assertions.assertFalse(MathTools.isEven(1)); }
如果我们想要测试更多的值,按照上面的方法就有点困难了,我们可以使用下面的方来的参数化一下:
我们使用@ParameterizedTest 替换了@Test 注解,同时我们使用了@ValueSource 来提供了一个参数源。
@ParameterizedTest @ValueSource(ints = {0, 2, 4, 6, 8, 10, 100, 1000}) void testIsEven(int number) { Assertions.assertTrue(MathTools.isEven(number)); }
如果参数很多的化,还可以使用自定义的方法@MethodSource来生成入参
@ParameterizedTest @MethodSource("generateEvenNumbers") void testIsEvenRange(int number) { Assertions.assertTrue(MathTools.isEven(number)); } static IntStream generateEvenNumbers() { return IntStream.iterate(0, i -> i + 2).limit(500); }
@Parameterized 支持的数据源注解包含下面这些
  • ValueSource: Specifies a hardcoded list of integers or Strings
  • MethodSource: Invokes a static method that generates a stream or collection of items.
  • EnumSource: Specifies an enum, whose values will be passed to the test method. It allows you to iterate over all enum values or include or exclude specific enum values.
  • CsvSource: Specifies a comma-separated list of values.
  • CsvFileSource: Specifies a path to a comma-separated value file with test data.
  • ArgumentSource: Allows you to specify an argument provider that generates a stream of arguments to be passed to your test method
  • NullSource: Passes null to your test method if you are working with Strings, collections, or arrays. You can include this annotation with other annotations, such as the ValueSource, to write code that tests a collection of values and null
  • EmptySource: Includes an empty value if you are working with Strings, collections, or arrays
  • NullAndEmptySource: Includes both null and an empty value if you are working with Strings, collections, or arrays

Junit5使用断言库Assertions

Junit5拥有原生的断言库,我们也可以选择更优雅的方法,一些第三方提供的库,比如:AssertJ,Hamcrest,Truth。 我们接下来介绍Hamcrest的使用。
POM中需要添加依赖:
<dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <version>2.2</version> <scope>test</scope> </dependency>
如果要断言一个字符串可以如下两种写法:
assertThat(name, is("Steve")); assertThat(name, equalsTo("Steve"));
Hamcrest支持下列的匹配类型
  • Objects: equalTo, hasToString, instanceOf, isCompatibleType, notNullValue, nullValue, sameInstance
  • Text: equalToIgnoringCase, equalToIgnoringWhiteSpace, containsString, endsWith, startsWith
  • Numbers: closeTo, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo
  • Logical: allOf, anyOf, not
  • Collections: array (compare an array to an array of matchers), hasEntry, hasKey, hasValue, hasItem, hasItems, hasItemInArray
一个简单的示例
package com.javaworld.geekcap.hamcrest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; class HamcrestDemoTest { @Test @DisplayName("String Examples") void stringExamples() { String s1 = "Hello"; String s2 = "Hello"; assertThat("Comparing Strings", s1, is(s2)); assertThat(s1, equalTo(s2)); assertThat(s1, sameInstance(s2)); assertThat("ABCDE", containsString("BC")); assertThat("ABCDE", not(containsString("EF"))); } @Test @DisplayName("List Examples") void listExamples() { // Create an empty list List<String> list = new ArrayList<>(); assertThat(list, isA(List.class)); assertThat(list, empty()); // Add a couple items list.add("One"); list.add("Two"); assertThat(list, not(empty())); assertThat(list, hasSize(2)); assertThat(list, contains("One", "Two")); assertThat(list, containsInAnyOrder("Two", "One")); assertThat(list, hasItem("Two")); } @Test @DisplayName("Number Examples") void numberExamples() { assertThat(5, lessThan(10)); assertThat(5, lessThanOrEqualTo(5)); assertThat(5.01, closeTo(5.0, 0.01)); } }

JUnit5的生命周期

提供了如下的注解在你的类中:
  • @BeforeAll: A static method in your test class that is called before all of its tests run.
  • @AfterAll: A static method in your test class that is called after all of its tests run.
  • @BeforeEach: A method that is called before each individual test runs.
  • @AfterEach: A method that is called after each individual test runs.

JUnit5的新特性:Tag

Tag可以理解为环境的变量,用来区分不同环境下需要执行不同单元测试的需求。
借助这个功能可以让我们在打包的时候选择执行不同Tag的单元测试。
TestOne.java
package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag("Development") class TestOne { @Test void testOne() { System.out.println("Test 1"); } }
TestTwo.java
package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag("Development") class TestTwo { @Test void testTwo() { System.out.println("Test 2"); } }
TestThree.java
package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag("Production") class TestThree { @Test void testThree() { System.out.println("Test 3"); } }
我们可以执行下面的命令来选择不同的环境
mvn clean test -Dgroups="Development" mvn clean test -Dgroups="Production" mvn clean test -Dgroups="Development, Production" mvn clean test -DexcludedGroups="Production" # 排除某个tag

JUnit5使用Mockito三方库来Mock数据

目前为止,我们学习了简单的单元测试注解以及测试方法。但是真实的项目中,我们需要依赖一些其他组建,比如数据库,一些三方的方法等。如果手动来模拟这些数据是非常庞大的代码量,因此我们一般使用Mock的方式来模拟这些事件。

先来一个Mockito的简单示例:

Example repository (Repository.java)
package com.javaworld.geekcap.mockito; import java.sql.SQLException; import java.util.Arrays; import java.util.List; public class Repository { public List<String> getStuff() throws SQLException { // Execute Query // Return results return Arrays.asList("One", "Two", "Three"); } }
Example service (Service.java)
package com.javaworld.geekcap.mockito; import java.sql.SQLException; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; public class Service { private Repository repository; public Service(Repository repository) { this.repository = repository; } public List<String> getStuffWithLengthLessThanFive() { try { return repository.getStuff().stream() .filter(stuff -> stuff.length() < 5) .collect(Collectors.toList()); } catch (SQLException e) { return Arrays.asList(); } } }
Testing the service (ServiceTest.java)
package com.javaworld.geekcap.mockito; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.sql.SQLException; import java.util.Arrays; import java.util.List; @ExtendWith(MockitoExtension.class) class ServiceTest { @Mock Repository repository; @InjectMocks Service service; @Test void testSuccess() { // Setup mock scenario try { Mockito.when(repository.getStuff()).thenReturn(Arrays.asList("A", "B", "CDEFGHIJK", "12345", "1234")); } catch (SQLException e) { e.printStackTrace(); } // Execute the service that uses the mocked repository List<String> stuff = service.getStuffWithLengthLessThanFive(); // Validate the response Assertions.assertNotNull(stuff); Assertions.assertEquals(3, stuff.size()); } @Test void testException() { // Setup mock scenario try { Mockito.when(repository.getStuff()).thenThrow(new SQLException("Connection Exception")); } catch (SQLException e) { e.printStackTrace(); } // Execute the service that uses the mocked repository List<String> stuff = service.getStuffWithLengthLessThanFive(); // Validate the response Assertions.assertNotNull(stuff); Assertions.assertEquals(0, stuff.size()); } }
做一个简单的讲解:
  • @ExtendWith(MockitoExtension.class) 来引入了JUnit5的外部插件
  • MockitoExtension 中支持了其他的Mock注解
  • @Mock 创建了一个Mock的示例,后续需要指定这个Mock示例调用某个方法时的行为。
  • @InjectMocks 实例化了一个业务示例,就是我们想要真正执行的业务方法的示例。并且@Mock 中声明的Mokc数据会注入到@InjectMocks 的实例中。
  • 我们在测试getStuffWithLengthLessThanFive方法时,让方法中的数据库调用getStuff执行了mock数据,排除了三方接口的测试,只实现了这个接口的业务测试。
如果Mockito的功能无法满足某些需求,可以使用更高级的PowerMock

如何Mock@InjectMocks 注入的类中的某个方法

在一些场景中,我们要测试的方法可能调用该类中的其他方法,比如:
需要注意MyHandler 的注入使用了@Spy @InjectMocks两个注解来同时实现了两个注解的能力。
MyHandler.java
@Component public class MyHandler { @AutoWired private MyDependency myDependency; public int someMethod() { ... return anotherMethod(); } public int anotherMethod() {...} }
MyHandlerTest.java
@RunWith(MockitoExtension.class} class MyHandlerTest { @BeforeEach void beforeEach() { MockitoAnnotations.initMocks(this); } @Spy @InjectMocks private MyHandler myHandler; @Mock private MyDependency myDependency; @Test public void testSomeMethod() { doReturn(1).when(myHandler).anotherMethod(); assertEquals(myHandler.someMethod() == 1); verify(myHandler, times(1)).anotherMethod(); } }

踩坑记录