单元测试
什么是单元测试
参考维基百科: 单元测试(Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
单元测试的收益
- 发现问题:单元测试可以在软件开发的早期就能发现问题。
- 适应变更:单元测试允许程序员在未来重构代码,并且确保模块依然工作正确。
- 简化集成:单元测试消除程序单元的不可靠,通过先测试程序部件再测试部件组装,使集成测试变得更加简单,更有信心。
- 文档记录:单元测试提供了系统的一种文档记录。借助于查看单元测试提供的功能和单元测试中如何使用程序单元,开发人员可以直观的理解程序单元的基础 API。
- 表达设计:在测试驱动开发的软件实践中,单元测试可以取代正式的设计。每一个单元测试案例均可以视为一项类、方法和待观察行为等设计元素。
单元测试的局限
测试不可能发现所有的程序错误,单元测试也不例外。单元测试只测试程序单元自身的功能。因此,它不能发现集成错误、性能问题、或者其他系统级别的问题。
Junit单元测试
常用注解
- @Test:注解的 public void 方法将会被当做测试用例,JUnit 每次都会创建一个新的测试实例,然后调用 @Test 注解方法,任何异常的抛出都会认为测试失败。
- @Before:执行测试方法前需要统一预处理的一些逻辑,该方法在每个 @Test 注解方法被执行前执行
- @After:该方法在每个 @Test 注解方法执行后被执行。
- @BeforeClass:该方法会在所有测试方法被执行前执行一次,并且只执行一次。
- @AfterClass:该方法会在所有测试方法被执行后执行一次,并且只执行一次。
- @Ignore:对包含测试类的类或 @Test 注解方法使用 @Ignore 注解将使被注解的类或方法不会被当做测试执行;JUnit 执行结果中会报告被忽略的测试数。
- @FixMethodOrder:Junit 4.11 里增加了指定测试方法执行顺序的特性,测试类的执行顺序可通过对测试类添加注解 @FixMethodOrder(value)来指定。
- MethodSorters.DEFAULT:(默认)默认顺序由方法名 hashcode 值来决定,如果 hash 值大小一致,则按名字的字典顺序确定。
- MethodSorters.NAME_ASCENDING:(推荐)按方法名称的进行排序,由于是按字符的字典顺序,所以以这种方式指定执行顺序会始终保持一致
- MethodSorters.JVM:按 JVM 返回的方法名的顺序执行,此种方式下测试方法的执行顺序是不可预测的,即每次运行的顺序可能都不一样
Junit实例
实例代码如下:
package com.ejyi.demo.springboot.server.web.unittest;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import java.util.Date;
/**
* @author tree
* @version 1.0
* @description Junit单元测试示例
* @create 2019-01-17 8:26 PM
*/
@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
public class JUnitTest {
private static int num = 0;
@Test
public void test1(){
System.out.println("test1 run...");
System.out.println("test1 num = " + num);
num = 1;
System.out.println("test1 num = " + num);
}
@Test
public void test2(){
System.out.println("test2 run...");
System.out.println("test2 num = " + num);
num = 2;
System.out.println("test2 num = " + num);
}
@Test
public void test3(){
System.out.println("test3 run...");
System.out.println("test3 num = " + num);
num = 3;
System.out.println("test3 num = " + num);
}
@Test
public void test4(){
System.out.println("test4 run...");
System.out.println("test4 num = " + num);
num = 4;
System.out.println("test4 num = " + num);
Long time = System.currentTimeMillis();
Date date1 = new Date(time);
Date date2 = new Date(time);
Assert.assertEquals(date1, date2);
Assert.assertTrue(date1 == date2);
}
@Before
public void beforeEveryTest(){
System.out.println("=== beforeEveryTest ===");
}
@After
public void afterEveryTest(){
System.out.println("=== afterEveryTest ===");
}
// must be static
@BeforeClass
public static void beforeClassTest(){
System.out.println("===beforeClassTest===");
}
// must be static
@AfterClass
public static void afterClassTest(){
System.out.println("===afterClassTest===");
}
}
通过Assert可以对返回值和预期值进行对比。输出如下:
===beforeClassTest===
=== beforeEveryTest ===
test1 run...
test1 num = 0
test1 num = 1
=== afterEveryTest ===
=== beforeEveryTest ===
test2 run...
test2 num = 1
test2 num = 2
=== afterEveryTest ===
=== beforeEveryTest ===
test3 run...
test3 num = 2
test3 num = 3
=== afterEveryTest ===
=== beforeEveryTest ===
test4 run...
test4 num = 3
test4 num = 4
=== afterEveryTest ===
java.lang.AssertionError
at org.junit.Assert.fail(Assert.java:86)
at org.junit.Assert.assertTrue(Assert.java:41)
at org.junit.Assert.assertTrue(Assert.java:52)
...
===afterClassTest===
可以看到对于 “Assert.assertTrue(date1 == date2);”,提示失败。
SpringBoot的单元测试
Spring Boot 单元测试可以参考官网文档: https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html
下面针对我认为比较常用用法进行说明。
常用注解
- @RunWith:就是一个运行器,告诉 junit 的框架应该是使用哪个 testRunner。
- @RunWith(JUnit4.class) 就是指用 JUnit4 来运行
- @RunWith(SpringRunner.class), 让测试运行于 Spring 测试环境
- @SpringBootTest:是 SpringBoot 自 1.4.0 版本开始引入的一个用于测试的注解。webEnvironment属性指定启动策略。一般用 RANDOM_PORT 随机端口
- @ActiveProfiles:指定用到的配置文件名。
- @AutoConfigureMockMvc:自动mock MockMvc对象;
- @SpyBean:用于mock某一个对象;
实例
定义基类
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("local")
@AutoConfigureMockMvc
public class BaseTest {
}
实际使用中 DemoControllerTestByMockMvc 类通过mockMvc对象进行接口单元测试,实际代码如下:
public class DemoControllerTestByMockMvc extends BaseTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testQuery() throws Exception { this.mockMvc.perform(MockMvcRequestBuilders.get("/demo/v1/demo/2").contentType(MediaType.APPLICATION_JSON_UTF8))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andReturn().getResponse().getContentAsString();
}
@Test
public void testAdd() throws Exception {
Long time = System.currentTimeMillis();
DemoModel demoModel = new DemoModel(null, time.toString(), 34.1D, (byte) 1, new Date(), new Date());
this.mockMvc.perform(MockMvcRequestBuilders.post("/demo/v1/demo")
.contentType(MediaType.APPLICATION_JSON_UTF8).content(JsonUtils.toJson(demoModel)))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andReturn().getResponse().getContentAsString();
}
}
DemoControllerTestByMockService类,通过 @SpyBean 注解和 BDDMockito 搭配进行方法的mock,而且可以仅针对特定参数进行mock,不会影响其他的访问行为。代码如下:
public class DemoControllerTestByMockService extends BaseTest {
@SpyBean
private DemoService demoService;
@Autowired
private MockMvc mockMvc;
@Test
public void test01Query() throws Exception {
DemoModel demoModel = new DemoModel();
demoModel.setId(100L);
demoModel.setCode("100");
demoModel.setUpdateTime(new Date());
demoModel.setStatus((byte)1);
demoModel.setScore(1.1D);
demoModel.setCreateTime(new Date());
BDDMockito.given(this.demoService.queryById(demoModel.getId())).willReturn(CallResult.makeCallResult(true, ResultEnum.SUCCESS, demoModel, null));
CallResult<DemoModel> demoModelCallResult = this.demoService.queryById(100L);
System.out.println(JsonUtils.toJson(demoModelCallResult));
Assert.assertTrue(demoModelCallResult.isSuccess());
Assert.assertTrue(demoModelCallResult.getBusinessResult().getCode().equals("100"));
demoModelCallResult = this.demoService.queryById(1L);
System.out.println(JsonUtils.toJson(demoModelCallResult));
Assert.assertTrue(demoModelCallResult.isSuccess());
}
@Test
public void test02Query() throws Exception {
DemoModel demoModel = new DemoModel();
demoModel.setId(100L);
demoModel.setCode("100");
demoModel.setUpdateTime(new Date());
demoModel.setStatus((byte)1);
demoModel.setScore(1.1D);
demoModel.setCreateTime(new Date());
BDDMockito.given(this.demoService.queryById(demoModel.getId())).willReturn(CallResult.makeCallResult(true, ResultEnum.SUCCESS, demoModel, null));
this.mockMvc.perform(MockMvcRequestBuilders.get("/demo/v1/demo/100").contentType(MediaType.APPLICATION_JSON_UTF8))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.code").value("100"))
.andReturn().getResponse().getContentAsString();
this.mockMvc.perform(MockMvcRequestBuilders.get("/demo/v1/demo/1").contentType(MediaType.APPLICATION_JSON_UTF8))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.id").value(1))
.andReturn().getResponse().getContentAsString();
}
}
还可以直接基于数据库操作进行测试, 代码如下:
public class ActiveInfoMapper01Test extends BaseTest {
@Autowired
private ActiveInfoMapper activeInfoMapper;
@Test
public void insert() throws Exception {
int count = 0;
Long time = System.currentTimeMillis();
ActiveInfoPO activeInfoPO = new ActiveInfoPO(time, time.toString(), 10, 1.1D, 1, 1, new Date(), new Date());
count = activeInfoMapper.insert(activeInfoPO);
assertEquals(1, count);
System.out.printf("==========================================ActiveInfoMapper01Test.insert%s", JsonUtils.toJson(activeInfoPO));
}
@Test
public void queryById() throws Exception {
ActiveInfoPO activeInfoPO = activeInfoMapper.selectById(2L);
Assertions.assertThat(activeInfoPO.getId()).isEqualTo(2L);
System.out.printf("==========================================ActiveInfoMapper01Test.queryById%s", JsonUtils.toJson(activeInfoPO));
}
}
基于H2数据库进行单元测试
有时候跑集成测试的时候,用例较多会相互影响,因此可以考虑基于H2的内存数据库来作为测试用库。新建测试配置文件application-unittest.yml.
spring:
datasource:
hikari:
jdbc-url: jdbc:h2:mem:springboot_demo;MODE=MYSQL;DB_CLOSE_DELAY=-1
username: root
password:
driver-class-name: org.h2.Driver
maximum-pool-size: 15
pool-name: unit-test-db
name: demo
filters: config,log4j,stat
platform: h2
h2:
console:
enabled: true
同时还用了flyway模块,对数据库基础数据进行创建,database-rider模块进行测试环境数据创建,非常实用,github地址:https://github.com/database-rider/database-rider 代码如下:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("unittest")
@AutoConfigureMockMvc
@DBUnit()
public class BaseH2MockTest {
private static String DB_URL = "jdbc:h2:mem:springboot_demo;MODE=MYSQL;DB_CLOSE_DELAY=-1";
private static String DB_USER = "root";
private static String DB_PASSWORD = "";
private static Flyway flyway;
@Rule
public DBUnitRule dbUnitRule = DBUnitRule.
instance(() -> flyway.getDataSource().getConnection());
@BeforeClass
public static void initMigration() throws SQLException {
flyway = Flyway.configure().dataSource(DB_URL, DB_USER, DB_PASSWORD)
.locations("filesystem:src/test/resources/db/migration").load();
flyway.migrate();
}
@AfterClass
public static void cleanMigration() throws SQLException {
// flyway.clean();
// if (!connection.isClosed()) {
// connection.close();
// }
}
}
DemoControllerTestByH2Mock类进行测试,其中@Sql是spring自带的数据初始化注解,@DataSet是database-rider提供的注解,@Sql对于执行多次可能会相互影响,可以把test03Query的注释去掉试着跑一下。
public class DemoControllerTestByH2Mock extends BaseH2MockTest {
private final static Logger logger = LoggerFactory.getLogger(DemoControllerTestByH2Mock.class);
@Autowired
private MockMvc mockMvc;
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
public void test01Query() throws Exception{
ResponseEntity<String> responseEntity = restTemplate.getForEntity("/demo/v1/demo/1", String.class);
System.out.println("-----------------------------------------DemoControllerTestByH2Mock.testQuery::"+responseEntity);
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
Assertions.assertThat(responseEntity.getBody()).contains("\"code\":200").contains("\"id\":1");
}
@Test
@Sql(value = {"/db/data/init_active_info.sql"})
public void test02Query() throws Exception{
ResponseEntity<String> responseEntity = restTemplate.getForEntity("/demo/v1/demo/3", String.class);
System.out.println("-----------------------------------------DemoControllerTestByH2Mock.testQuery1::"+responseEntity);
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
Assertions.assertThat(responseEntity.getBody()).contains("\"code\":200").contains("\"id\":3");
}
// @Test
// @Sql(value = {"/db/data/init_active_info.sql"})
// public void test03Query() throws Exception{
// this.mockMvc.perform(MockMvcRequestBuilders.get("/demo/v1/demo/4").contentType(MediaType.APPLICATION_JSON_UTF8))
// .andDo(MockMvcResultHandlers.print())
// .andExpect(MockMvcResultMatchers.status().isOk())
// .andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
// .andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
// .andExpect(MockMvcResultMatchers.jsonPath("$.data.id").value(4))
// .andReturn().getResponse().getContentAsString();
// }
@Test
@DataSet(value = {"db/data/active_info.yml"})
public void test04Query() throws Exception{
ResponseEntity<String> responseEntity = restTemplate.getForEntity("/demo/v1/demo/8", String.class);
System.out.println("-----------------------------------------DemoControllerTestByH2Mock.testQuery2::"+responseEntity);
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
Assertions.assertThat(responseEntity.getBody()).contains("\"code\":200").contains("\"id\":8");
}
@Test
@DataSet(value = {"db/data/active_info.yml"})
public void test05Query() throws Exception{
this.mockMvc.perform(MockMvcRequestBuilders.get("/demo/v1/demo/9").contentType(MediaType.APPLICATION_JSON_UTF8))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.id").value(9))
.andReturn().getResponse().getContentAsString();
}
}
以上介绍的几种测试方式,应该可以覆盖我们绝大部分的测试场景了,对于增强代码的健壮性和可维护性,非常建议大家写测试用例。
代码见如下地址: