JUnit5单元测试
本文章的编程环境为:使用Gradle构建的SpringBoot工程
参考资料:
- https://junit.org/junit4/
- https://junit.org/junit5/
- https://www.bilibili.com/video/BV1u4411T78k?p=2
概述
简介
JUnit 是一个开源的 Java 语言的单元测试框架
- 专门针对 Java 语言设计,使用最广泛
- JUnit 是事实上的标准单元测试框架
测试驱动开发(TDD)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xfO5dppi-1598015815152)(C:/Users/86189/AppData/Roaming/Typora/typora-user-images/image-20200821151848039.png)]
单元测试的优点
- 确保单个方法运行正常
- 如果修改了方法代码,只需确保其对应的单元测试通过
- 测试代码本身就可以作为示例代码
- 可以自动化运行所有测试并获得报告
单元测试的特点
- 使用断言(Assertion)测试期望的结果
- 可以方便地组织和运行测试
- 可以方便地查看测试结果
- 常用 IDE 都继承了 JUnit
- 可以方便地集成到 Maven
JUnit的设计
- TestCase:一个 TestCase 表示一个测试
- TestSuite:一个 TestSuite 包含一组 TestCase,表示一组测试
- TestFixture:一个 TestFixture 表示一个测试环境
- TestResult:用于手机测试结果
- TestRunner:用于运行测试
- TestListener:用于监听测试过程,收集测试数据
- Assert:用于断言测试结果是否正确
JUnit 在 Spring 中的不足
1)导致多次Spring容器初始化问题
根据JUnit测试方法的调用流程,每执行一个测试方法都会创建一个测试用例的实例并调用setUp()方法。由于一般情况下,我们在setUp()方法中初始化Spring容器,这意味着如果测试用例有多少个测试方法,Spring容器就会被重复初始化多次。虽然初始化Spring容器的速度并不会太慢,但由于可能会在Spring容器初始化时执行加载Hibernate映射文件等耗时的操作,如果每执行一个测试方法都必须重复初始化Spring容器,则对测试性能的影响是不容忽视的;
使用Spring测试套件,Spring容器只会初始化一次
2)需要使用硬编码方式手工获取Bean
在测试用例类中我们需要通过ctx.getBean()方法从Spirng容器中获取需要测试的目标Bean,并且还要进行强制类型转换的造型操作。这种乏味的操作迷漫在测试用例的代码中,让人觉得烦琐不堪;
使用Spring测试套件,测试用例类中的属性会被自动填充Spring容器的对应Bean,无须在手工设置Bean!
3)数据库现场容易遭受破坏
测试方法对数据库的更改操作会持久化到数据库中。虽然是针对开发数据库进行操作,但如果数据操作的影响是持久的,可能会影响到后面的测试行为。举个例子,用户在测试方法中插入一条ID为1的User记录,第一次运行不会有问题,第二次运行时,就会因为主键冲突而导致测试用例失败。所以应该既能够完成功能逻辑检查,又能够在测试完成后恢复现场,不会留下“后遗症”;
使用Spring测试套件,Spring会在你验证后,自动回滚对数据库的操作,保证数据库的现场不被破坏,因此重复测试不会发生问题!
4)不方便对数据操作正确性进行检查
假如我们向登录日志表插入了一条成功登录日志,可是我们却没有对t_login_log表中是否确实添加了一条记录进行检查。一般情况下,我们可能是打开数据库,肉眼观察是否插入了相应的记录,但这严重违背了自动测试的原则。试想在测试包括成千上万个数据操作行为的程序时,如何用肉眼进行检查?
只要你继承Spring的测试套件的用例类,你就可以通过jdbcTemplate(或Dao等)在同一事务中访问数据库,查询数据的变化,验证操作的正确性!
Annotations
@BeforeEach & @AfterEach
@BeforeEach: Denotes that the annotated method should be executed before each
@Test
,@RepeatedTest
,@ParameterizedTest
, or@TestFactory
method in the current class; analogous to JUnit 4’s@Before
. Such methods are inherited unless they are overridden.
@AfterEach: Denotes that the annotated method should be executed after each
@Test
,@RepeatedTest
,@ParameterizedTest
, or@TestFactory
method in the current class; analogous to JUnit 4’s@After
. Such methods are inherited unless they are overridden.
- 在 @BeforeEach 方法中初始化测试资源
- 在 @AfterEach 方法中释放测试资源
@BeforeAll & @AfterAll
@BeforeAll: Denotes that the annotated method should be executed before all
@Test
,@RepeatedTest
,@ParameterizedTest
, and@TestFactory
methods in the current class; analogous to JUnit 4’s@BeforeClass
. Such methods are inherited (unless they are hidden or overridden) and must bestatic
(unless the “per-class” test instance lifecycle is used).
@AfterAll: Denotes that the annotated method should be executed after all
@Test
,@RepeatedTest
,@ParameterizedTest
, and@TestFactory
methods in the current class; analogous to JUnit 4’s@AfterClass
. Such methods are inherited (unless they are hidden or overridden) and must bestatic
(unless the “per-class” test instance lifecycle is used).
- @BeforeAll 静态方法初始化的对象只能存放在静态字段中
- 静态字段的状态会影响到所有的 @Test
超时 —— @Timeout
@Test
@Timeout(value = 1, unit = TimeUnit.MICROSECONDS)
void test07() {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
list.add(i);
}
}
禁用 —— @Disabled
Used to disable a test class or test method; analogous to JUnit 4’s
@Ignore
. Such annotations are not inherited.
重复测试 —— @RepeatedTest
Denotes that a method is a test template for a repeated test. Such methods are inherited unless they are overridden.
重复测试:使用参数 value 来指定重复测试的次数
参数化测试 —— @ParameterizedTest
Denotes that a method is a parameterized test. Such methods are inherited unless they are overridden.
参数源详解
value source:最简单的参数源,通过注解可以直接指定携带的运行参数。
- String values: @ValueSource(strings = {“foo”, “bar”, “baz”})
- Double values: @ValueSource(doubles = {1.5D, 2.2D, 3.0D})
- Long values: @ValueSource(longs = {2L, 4L, 8L})
- Integer values: @ValueSource(ints = {2, 4, 8})
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void test03(int n) {
Assertions.assertTrue(n > 0);
}
Enum Source:枚举参数源,允许我们通过将参数值由给定Enum枚举类型传入。并可以通过制定约束条件或正则匹配来筛选传入参数。
@ParameterizedTest
@EnumSource(value = DayInWeekEnum.class, names = {"MONDAY"})
void test04(DayInWeekEnum day) {
Assertions.assertTrue(EnumSet.of(DayInWeekEnum.MONDAY, DayInWeekEnum.FRIDAY).contains(day));
}
Method Source:通过其他的 Java 方法函数来作为参数源。引用的方法返回值必须是Stream、Iterator 或者 Iterable。
static Stream<String> stringStreamGenerator() {
return Stream.of("a", "b", "c", "d");
}
@ParameterizedTest
@MethodSource(value = {"stringStreamGenerator"})
void test05(String arg) {
System.out.println(arg);
Assertions.assertNotNull(arg);
}
Argument Source:通过参数类来作为参数源。这里引用的类必须实现ArgumentsProvider接口。
CSV Source:通过指定 csv(comma-separated-values,逗号分隔值,有时也称为字符分隔值,因为分隔字符也可以不是逗号)格式的注解作为参数源。
static Map<Integer, String> map;
@BeforeAll
static void mapAdding() {
map = new HashMap<>();
map.put(1, "Tom");
map.put(2, "Lucy");
map.put(3, "Jack");
}
@ParameterizedTest
@CsvSource({"1,Tom", "2,Lucy", "3,Jack"})
void test06(Integer id, String name) {
System.out.println(id + " -> " + name);
Assertions.assertTrue(map.containsKey(id));
Assertions.assertEquals(name, map.get(id));
}
CSV File Source:除了使用csv参数源,这里也支持使用csv文件作为参数源。
假设users.csv 文件包含如下csv格式的数据:1,Selma ‘\n’ 2,Lisa’\n’ 3,Tim(’\n’ :换行)
@ParameterizedTest
@CsvFileSource(resources = "/users.csv")
void testUsersFromCsv(long id, String name) {
assertTrue(idToUsername.containsKey(id));
assertTrue(idToUsername.get(id).equals(name));
}
参数转换
JUnit allows us to convert arguments to the target format we need in our tests.
隐式转换
JUnit 提供了很对内建的格式转化支持,特别是 string 和常用的数据类型。
以下是支持和 string 型进行转换的类型:
- Boolean
- Byte
- Character
- Short
- Integer
- Long
- Float
- Double
- Enum subclass
- Instant
- LocalDate
- LocalDateTime
- LocalTime
- OffsetTime
- OffsetDateTime
- Year
- YearMonth
- ZonedDateTime
显式转换
Junit5 中可以使用 @ConvertWith(MyConverter.class) 注解来实现 SimpleArgumentConverter。
标记与过滤 —— @Tag
可以在测试类上标记 @Tag("…") ,之后在 pom.xml 文件或 build.gradle 文件中可以对其进行过滤。
Assertions
Assertions’ basic methods
- 断言相等:assertEquals(100, x)
- 断言数组相等:assertArrayEquals({1, 2, 3}, x)
- 浮点数断言相等:assertEquals(3.1415, x, 0.0001)
- 断言为 null:assertNull(x)
- 断言为 true/false:assertTrue(x > 0) 、assertFalse(x < 0)
- 其他:assertNotEquals / assertNotNull
断言组 —— assertAll
此方法可实现1个用例中包含多个断言,遇到断言失败仍然会继续下一个断言。
assertAll 的 heading 将会在测试失败时,会在失败信息的开头,作为一系列失败点的开头信息,给予提示。
@ParameterizedTest
@CsvSource({"1,Tom", "2,Lucy", "3,Jack"})
void test06(Integer id, String name) {
System.out.println(id + " -> " + name);
// Assertions.assertTrue(map.containsKey(id));
// Assertions.assertEquals(name, map.get(id));
Assertions.assertAll("This is the heading of method: assertAll",
() -> Assertions.assertTrue(map.containsKey(id)),
() -> Assertions.assertEquals(name, map.get(id)),
() -> Assertions.assertEquals("1", map.get(id))
);
}
超时 —— assertTimeout
@Test
void test08() {
Assertions.assertTimeout(Duration.ofMillis(1), () -> {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add(i);
}
});
}
异常测试 —— assertThrows
使用 java8 中的 lambda 表达式配合 assertThrows 进行测试。
@Test
void test02() {
Exception exception = Assertions.assertThrows(ArithmeticException.class, () -> calculator.divide(2, 0));
Assertions.assertEquals("/ by zero", exception.getMessage());
}
JUnit的使用
Junit测试的执行顺序
- 实例化测试类
- 执行 @BeforeAll 方法:初始化非常耗时的资源,例如:创建数据库(针对所有测试,只执行一次,且必须为static void)
- 执行 @BeforeEach 方法:初始化测试对象,例如:I/O流
- 执行 @Test 方法:测试方法,在这里可以测试期望异常和超时时间
- 执行 @AfterEach 方法:释放资源,销毁 @Before 创建的测试对象
- 执行 @AfterAll 方法:清理 @BeforeAll 创建的资源,例如:删除数据库(针对所有测试,只执行一次,且必须为static void)
JUnit4 与 JUnit5 的不同
- 注解在 org.junit.jupiter.api 包中,断言在 org.junit.jupiter.api.Assertions 类中,前置条件在 org.junit.jupiter.api.Assumptions 类中。
- 把@Before 和@After 替换成@BeforeEach 和@AfterEach。
- 把@BeforeClass 和@AfterClass 替换成@BeforeAll 和@AfterAll。
- 把@Ignore 替换成@Disabled。
- 把@Category 替换成@Tag。
- 把@RunWith、@Rule 和@ClassRule 替换成@ExtendWith。
配合 Spring Boot 使用 JUnit5
- 在测试类上加 @SpringBootTest 注解
- 可以使用 @Autowired 注解自动注入要测试的服务等
Mock 模拟
@MockBean & @SpyBean
- @MockBean
- @SpyBean
@SpringBootTest
class JunitDemoApplicationTests {
// static Calculator calculator;
//
// @BeforeAll
// static void createCalculator() {
// calculator = new Calculator();
// }
@MockBean
Calculator1 calculator1;
@SpyBean
Calculator2 calculator2;
@Test
void test00() {
when(calculator1.add(1, 2)).thenReturn(3);
Assertions.assertEquals(3, calculator1.add(1, 2));
}
@Test
void test01() {
Assertions.assertEquals(3, calculator2.add(1, 2));
}
}
MockMvc —— 模拟 HTTP 请求
MockMvc是由spring-test包提供,实现了对Http请求的模拟,能够直接使用网络的形式,转换到Controller的调用,使得测试速度快、不依赖网络环境。同时提供了一套验证的工具,结果的验证十分方便。
接口MockMvcBuilder,提供一个唯一的build方法,用来构造MockMvc。主要有两个实现:StandaloneMockMvcBuilder和DefaultMockMvcBuilder,分别对应两种测试方式,即独立安装和集成Web环境测试(并不会集成真正的web环境,而是通过相应的Mock API进行模拟测试,无须启动服务器)。MockMvcBuilders提供了对应的创建方法standaloneSetup方法和webAppContextSetup方法,在使用时直接调用即可。
- mockMvc.perform执行一个请求。
- MockMvcRequestBuilders.get(“XXX”)构造一个请求。
- ResultActions.param添加请求传值
- ResultActions.accept(MediaType.TEXT_HTML_VALUE))设置返回类型
- ResultActions.andExpect添加执行完成后的断言。
- ResultActions.andDo添加一个结果处理器,表示要对结果做点什么事情,比如此处使用MockMvcResultHandlers.print()输出整个响应结果信息。
- ResultActions.andReturn表示执行完成后返回相应的结果。
常用的测试
- 测试普通控制器
mockMvc.perform(get("/user/{id}", 1)) //执行请求
.andExpect(model().attributeExists("user")) //验证存储模型数据
.andExpect(view().name("user/view")) //验证viewName
.andExpect(forwardedUrl("/WEB-INF/jsp/user/view.jsp"))//验证视图渲染时forward到的jsp
.andExpect(status().isOk())//验证状态码
.andDo(print()); //输出MvcResult到控制台
1234567
- 得到MvcResult自定义验证
MvcResult result = mockMvc.perform(get("/user/{id}", 1))//执行请求
.andReturn(); //返回MvcResult
Assert.assertNotNull(result.getModelAndView().getModel().get("user")); //自定义断言
1234
- 验证请求参数绑定到模型数据及Flash属性
mockMvc.perform(post("/user").param("name", "zhang")) //执行传递参数的POST请求(也可以post("/user?name=zhang"))
.andExpect(handler().handlerType(UserController.class)) //验证执行的控制器类型
.andExpect(handler().methodName("create")) //验证执行的控制器方法名
.andExpect(model().hasNoErrors()) //验证页面没有错误
.andExpect(flash().attributeExists("success")) //验证存在flash属性
.andExpect(view().name("redirect:/user")); //验证视图
123456
- 文件上传
byte[] bytes = new byte[] {1, 2};
mockMvc.perform(fileUpload("/user/{id}/icon", 1L).file("icon", bytes)) //执行文件上传
.andExpect(model().attribute("icon", bytes)) //验证属性相等性
.andExpect(view().name("success")); //验证视图
1234
- JSON请求/响应验证
String requestBody = "{\"id\":1, \"name\":\"zhang\"}";
mockMvc.perform(post("/user")
.contentType(MediaType.APPLICATION_JSON).content(requestBody)
.accept(MediaType.APPLICATION_JSON)) //执行请求
.andExpect(content().contentType(MediaType.APPLICATION_JSON)) //验证响应contentType
.andExpect(jsonPath("$.id").value(1)); //使用Json path验证JSON 请参考http://goessner.net/articles/JsonPath/
String errorBody = "{id:1, name:zhang}";
MvcResult result = mockMvc.perform(post("/user")
.contentType(MediaType.APPLICATION_JSON).content(errorBody)
.accept(MediaType.APPLICATION_JSON)) //执行请求
.andExpect(status().isBadRequest()) //400错误请求
.andReturn();
Assert.assertTrue(HttpMessageNotReadableException.class.isAssignableFrom(result.getResolvedException().getClass()));//错误的请求内容体
12345678910111213141516
- 异步测试
//Callable
MvcResult result = mockMvc.perform(get("/user/async1?id=1&name=zhang")) //执行请求
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(CoreMatchers.instanceOf(User.class))) //默认会等10秒超时
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1));
12345678910
- 全局配置
mockMvc = webAppContextSetup(wac)
.defaultRequest(get("/user/1").requestAttr("default", true)) //默认请求 如果其是Mergeable类型的,会自动合并的哦mockMvc.perform中的RequestBuilder
.alwaysDo(print()) //默认每次执行请求后都做的动作
.alwaysExpect(request().attribute("default", true)) //默认每次执行后进行验证的断言
.build();
mockMvc.perform(get("/user/1"))
.andExpect(model().attributeExists("user"));
简单的例子
创建 VO 对象:
@Data
public class TestVO {
private Integer id;
private String name;
}
创建控制器:
@RestController
public class MyController {
@GetMapping(value = {"/test"})
public Object test() {
TestVO testVO = new TestVO();
testVO.setId(1);
testVO.setName("Test1");
return testVO;
}
}
创建测试类:
@SpringBootTest
class MyControllerTest {
private MockMvc mockMvc;
@Autowired
private WebApplicationContext webApplicationContext;
@Test
void test() {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
// 发送 get 请求
// public static org.springframework.test.web.servlet.request
// .MockHttpServletRequestBuilder get(@NotNull String urlTemplate, Object... uriVars)
RequestBuilder request = get("http://localhost:8080/test");
try {
String response = mockMvc.perform(request).andReturn().getResponse().getContentAsString();
System.out.println(response);
// jackson 解析
ObjectMapper mapper = new ObjectMapper();
TestVO testVO = mapper.readValue(response, TestVO.class);
System.out.println(testVO);
Assertions.assertEquals(1, testVO.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
}
事务控制
加 @Rollback 注解即可实现在测试方法完成后的回滚
注:笔者上网查了许久,发现都是通过 @Transactional 与 @Rollback 配合使用的,但笔者在 2020/8/21 使用 Spring Boot 进行单元测试的时候,发现其已无 @Transactional 注解,而且只使用 @Rollback 注解就可以完成回滚操作。
为了测试单独使用 @Rollback 是否可以达成事务回滚的效果,笔者写了以下的测试类:
@SpringBootTest
class CalculatorServiceTest { // 两个标有 @Test 的测试方法,会依照方法名开头字母(的ASCII码值)升序进行
private int a = 0; // a 的默认值为 0
@Test
@Rollback // 回滚注解
void test0() {
a = 1; // 将 a 的值修改为 1
System.out.println("a = " + a); // 在标记了 @Rollback 回滚注解的方法结束前,输出 a 的值,
}
@Test
void test1() {
System.out.println("a = " + a);
}
}
输出内容如下:
...(日志信息)
a = 1 // test0() 的输出结果
a = 0 // test1() 的输出结果
BUILD SUCCESSFUL in 2s
4 actionable tasks: 2 executed, 2 up-to-date
19:22:25: Task execution finished ':test --tests "xxx.yyyy.service.CalculatorServiceTest"'.
发现 a 在 test0() 方法中确实被修改为了 1,而且在 test0() 方法结束后确实也被修改回去了。姑且推测其是 “回滚成功” 了。
再看向 @Rollback 的源码(省略了开头的 Copyright):
package org.springframework.test.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* {@code @Rollback} is a test annotation that is used to indicate whether
* a <em>test-managed transaction</em> should be <em>rolled back</em> after
* the test method has completed.
*
* <p>Consult the class-level Javadoc for
* {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener}
* for an explanation of <em>test-managed transactions</em>.
*
* <p>When declared as a class-level annotation, {@code @Rollback} defines
* the default rollback semantics for all test methods within the test class
* hierarchy. When declared as a method-level annotation, {@code @Rollback}
* defines rollback semantics for the specific test method, potentially
* overriding class-level default commit or rollback semantics.
*
* <p>As of Spring Framework 4.2, {@code @Commit} can be used as direct
* replacement for {@code @Rollback(false)}.
*
* <p><strong>Warning</strong>: Declaring {@code @Commit} and {@code @Rollback}
* on the same test method or on the same test class is unsupported and may
* lead to unpredictable results.
*
* <p>This annotation may be used as a <em>meta-annotation</em> to create
* custom <em>composed annotations</em>. Consult the source code for
* {@link Commit @Commit} for a concrete example.
*
* @author Sam Brannen
* @since 2.5
* @see Commit
* @see org.springframework.test.context.transaction.TransactionalTestExecutionListener
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Rollback {
/**
* Whether the <em>test-managed transaction</em> should be rolled back
* after the test method has completed.
* <p>If {@code true}, the transaction will be rolled back; otherwise,
* the transaction will be committed.
* <p>Defaults to {@code true}.
*/
boolean value() default true;
}
其中有两段话较为关键:
@Rollback is a test annotation that is used to indicate whether a test-managed transaction should be rolled back after the test method has completed.
Whether the test-managed transaction should be rolled back after the test method has completed.
If true, the transaction will be rolled back; otherwise, the transaction will be committed.
Defaults to true.
第一段话是注释在整个 @Rollback 注解类上的,大意为:@Rollback 用于在测试完成之后,回滚其所标记的类/方法。
第二段话是注释的 value() 上的,该属性表示 “是否开启回滚” ,默认值为 true(即回滚,即不提交)。
结论:只使用 @Rollback 是可以完成测试回滚功能的。
总结
- 一个 TestCase 包含一组相关的测试方法
- 使用 Assert 断言测试结果(注意浮点数 assertEquals 要指定 delta)
- 每个测试方法必须完全独立
- 测试代码必须非常简单
- 不能为测试代码再编写测试
- 测试需要覆盖各种输入条件,特别是边界条件