单元测试简介
单元测试的目的是在集成测试和功能测试之前对软件中的可测试单元进行逐一检查和验证。单元测试是程序功能的基本保障。在面向对象编程中,通常认为单元测试的最小的单元就是方法。
单元测试的好处
提升软件质量
单元测试可以保障开发质量和程序的健壮性,运行失败的单测可以帮助我们快速排查和定位问题,使得问题再被带到线上之前(或移交到测试之前)完成修复,降低修复成本。
促进代码优化
单测是由开发工程师自己编写和维护的,这会促使开发工程师不断地审视自己的代码,白盒地去思考代码逻辑,更好的进行设计和优化。
提升研发效率
单元测试的编写和维护表面上占用了项目的研发时间,增加了开发的工作量(误区)。实际上,在后续的联调、集成和回归测试阶段,单元测试覆盖率高的代码通常缺陷少、问题易修复,有利于提升项目整体的研发效率。
增加重构的信心
代码重构可以让我们的代码、设计、和架构保持活力,防止腐化,应该是一项随时随地都要进行的工作,但代码重构往往牵一发动全身,比如简单的字段名修改可能引发一系列错误。100%的单元测试执行通过率可以很大程度上减少代码维护和重构带来的风险
总结
单元测试是保证软件质量和效率的重要手段之一,是具有长远收益的工作。
单元测试的基本原则
宏观上要符合AIR原则
automatic 自动化
independent 独立性
repeatable可重复
微观上要符合BCDE原则
B: Broder 边界值测试,包括循环边界,特殊取值,特殊时间点,数据顺序等
C: Corect 正确的输入,并得到预期的结果
D:Design,与设计文档相结合,并编写单元测试
E:Error 非法数据、异常流程、非业务允许输入
依赖或关联功能不具备时,采用Mock。如以下因素:
功能因素:被测试方法或内部调用不可用
时间因素:与时间点相关的功能点
环境因素
数据因素
其他因素:简化测试编写
单元测试覆盖率
粗粒度的覆盖率
类覆盖率
方法覆盖率
细粒度的覆盖率
行覆盖率
分支覆盖率
条件判定覆盖率
条件组合覆盖
单元测试用例的编写
命名规则:
类名
单元测试类的定义与被测类一一对应,放置于与被测类相同的包路径下,并与被测试类名称加上Test命名。例如,DemoService的测试类(src/main/java/com.huawei.demo),应该命名为DemoServiceTest,并放置在src/test/java/com.huawei.demo目录下。
方法名,提倡以下两种写法:
- 以test开头,然后加待测试场景和期待结果的命名方式,如testDecodeUserTokenSuccess
- should...When结构,如shouldSuccessWhenDecodeUserToken
单元测试注解说明
- @BeforeClass 全局只会执行一次,而且是第一个运行
- @Before 在测试方法运行之前运行
- @Test 测试方法
- @After 在测试方法运行之后允许
- @AfterClass 全局只会执行一次,而且是最后一个运行
- @Ignore 忽略此方法
断言与假设
- 1.assertTrue/False ([String message,]boolean condition); 判断一个条件是true还是false
- 2.fail ([String message,]); 失败,可以有消息,也可以没有消息。
- 3.assertEquals( [String message,]Object expected,Object actual); 判断是否相等,可以指定输出错误信息。 第一个参数是期望值,第二个参数是实际的值
- 4.assertNotNull/Null( [String message,]Object obj); 判读一个对象是否非空(非空)。
- 5.assertSame/NotSame ([String message,]Object expected,Object actual); 判断两个对象是否指向同一个对象。看 内存地址 。
- 7.failNotSame/failNotEquals (String message, Object expected, Object actual) 当不指向同一个内存地址或者不相等的时候,输出错误信息。 注意信息是必须的,而且这个输出是格式化过的。
实践例子
- 建立一个通用的抽象Test基类,供其他测试类继承,减少重复代码和提高用例编写效率
// 指定junit运行器
@RunWith(SpringRunner.class)
// 配置文件读取及环境准备
@SpringBootTest(classes = PldContentApplication.class)
// 注入MockMvc对象
@AutoConfigureMockMvc
//由于是Web项目,Junit需要模拟ServletContext,因此我们需要给我们的测试类加上@WebAppConfiguration。
@WebAppConfiguration
public abstract class BaseApplicationTest {
protected MockMvc mvc;
@Autowired
private WebApplicationContext context;
@PersistenceContext
protected EntityManager entityManager;
@Before
public void setupMockMvc() {
mvc = MockMvcBuilders.webAppContextSetup(context).build();
}
/**
* 基本断言
* @param action ResultActions对象
* @return ResultActions对象
* @throws Exception
*/
protected ResultActions basicExpect(ResultActions action) throws Exception {
action.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
action.andExpect(jsonPath("$.code", Matchers.is(ResultCodeEnum.SUCCESS.getCode())))
.andExpect(jsonPath("$.msg", Matchers.is(ResultCodeEnum.SUCCESS.getDesc())));
return action;
}
}
- 使用MockMvc模拟http请求测试Controller的接口方法
/**
* 用户内容接口-单元测试
*/
@Slf4j
@Transactional
public class DemoControllerTest extends BaseApplicationTest {
@Before
public void dataReady(){
// 前置数据,如果加了@Transactional,可保证数据执行完毕后自动回退,
//不污染数据库。
String sql_queryInitDataForReply = "INSERT INTO `pld_content`.`t_advice` (`id`, `create_by`, `date_created`, `date_last_update`, `status`, `update_by`, `version`, `app_code`, `author`, `content`, `expect_time`, `file_link`, `first_category`, `module_id`, `second_category`, `step`, `title`) VALUES ('0000000000000000000000000000000', 'efba13826d744c609987b4fa71cef2e0', '2019-02-28 10:03:42', '2019-05-30 16:54:37', '1', 'efba13826d744c609987b4fa71cef2e0', '0', 'pld_rongshanportal', 'admin', 'content test', '2019-02-28 00:00:00', 'oos_url_test1;oos_url_test2;', 'd', 'js03', 'd1', '1', '测试zuul的bug建议')";
entityManager.createNativeQuery(sql_queryInitDataForReply).executeUpdate();
String sql_ForReply1 = "INSERT INTO `pld_content`.`t_label` (`id`, `create_by`, `date_created`, `date_last_update`, `status`, `update_by`, `version`, `name`, `type`) VALUES ('000001', 'INIT', '2019-01-21 10:18:08', '2019-02-20 19:53:19', '1', 'INIT', '0', '金石综合管理平台', '1')";
entityManager.createNativeQuery(sql_ForReply1).executeUpdate();
String sql_ForReply2 = "INSERT INTO `pld_content`.`t_advice_label` (`id`, `create_by`, `date_created`, `date_last_update`, `status`, `update_by`, `version`, `advice_id`, `label_id`) VALUES ('000002', 'efba13826d744c609987b4fa71cef2e0', '2019-02-28 10:03:42', '2019-02-28 10:06:45', '1', 'efba13826d744c609987b4fa71cef2e0', '0', '0000000000000000000000000000000', '000001')";
entityManager.createNativeQuery(sql_ForReply2).executeUpdate();
String sql_ForReply3 = "INSERT INTO `pld_content`.`t_advice_reply` (`id`, `create_by`, `date_created`, `date_last_update`, `status`, `update_by`, `version`, `accepter_id`, `advice_id`, `content`, `sender_id`, `sender_name`, `step`) VALUES ('000003', '0366e11e68044b8aba671c1a2918f7ba', '2019-06-12 18:29:24', '2019-06-12 18:29:54', '1', '0366e11e68044b8aba671c1a2918f7ba', '0', '0366e11e68044b8aba671c1a2918f7ba', '0000000000000000000000000000000', '1111111111111111111', '0366e11e68044b8aba671c1a2918f7ba', 'admin', '2')";
entityManager.createNativeQuery(sql_ForReply3).executeUpdate();
}
/**
* 查看指定留言 get请求
* @throws Exception
*/
@Test
public void queryByAdvId() throws Exception {
String id ="ID000";
log.info("查看指定留言请求,请求参数{}",id);
ResultActions action = mvc.perform(MockMvcRequestBuilders
.get( "/advice/info/get" )
.contentType(MediaType.APPLICATION_JSON_UTF8).param("id",id));
action.andExpect(MockMvcResultMatchers.status().isOk());
super.basicExpect(action).andReturn();
}
/**
*分页查询回复记录 post请求
* @throws Exception
*/
@Test
public void queryReplyForPage() throws Exception {
AdviceReplyPageQueryDTO reqDTO = new AdviceReplyPageQueryDTO();
reqDTO.setAdviceId("ID000");
String requestJson = JSONObject.toJSONString(reqDTO);
log.info("分页查询回复记录,请求参数{}",requestJson);
ResultActions action = mvc.perform(MockMvcRequestBuilders
.post( "/advice/reply/list" )
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(requestJson));
action.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
action.andExpect(jsonPath("$.code",
Matchers.is(ResultCodeEnum.SUCCESS.getCode())))
.andExpect(jsonPath("$.msg",
Matchers.is(ResultCodeEnum.SUCCESS.getDesc())));
}
}
- 使用mock框架Mockito把某真实方法返回值替换成想要的对象
@Test
public void reconciliationSummary() throws Exception {
// 把真实服务方法fileSystemServicefileSystemService.downloadFile()
// 的返回值替换为本地资源targetFile.csv, 参数匹配条件为any
File targetFile = ResourceUtils.getFile("classpath:targetFile.csv");
Mockito.when(fileSystemService.downloadFile(Mockito.any()))
.thenReturn(targetFile);
File file = fileSystemService.downloadFile("/sftp/sourceFile.csv");
List<ReconciliationSummaryDTO> dtos = FileUtil
.readBuffer(file,ReconciliationSummaryDTO.class,FileUtil.SPLIT_S2);
dtos.stream().forEach(it-> System.out.println(JSONObject.toJSONString(dtos)));
}