概述
记录,为了快速查找。
用JUnit 5 和 Mockito。
参考阿里的java开发规范,单元测试主要遵循AIR原则,即自动化 (Automation)、独立性 (Independence)、 可重复性 (Repeatability)。
单测的基本目标:语句覆盖率达到 70%;核心模块的语句覆盖率和分支覆盖率都要达到 100%。(Jacoco)
编写单元测试代码遵守 BCDE 原则,以保证被测试模块的交付质量。
⚫ B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
⚫ C:Correct,正确的输入,并得到预期的结果。
⚫ D:Design,与设计文档相结合,来编写单元测试。
⚫ E:Error,强制错误信息输入(如:非法数据、异常流程、业务允许外等),并得到预期的结果。
和数据库相关的单元测试,可以设定自动回滚机制,不给数据库造成脏数据。或者对单元测试
产生的数据有明确的前后缀标识。(使用@SpringbootTest注解)
为了更方便地进行单元测试,业务代码应避免以下情况:
⚫ 构造方法中做的事情过多。
⚫ 存在过多的全局变量和静态方法。
⚫ 存在过多的外部依赖。
⚫ 存在过多的条件语句。
说明:多层条件语句建议使用卫语句、策略模式、状态模式等方式重构。
单元测试原则
参考阿里的java开发规范,单元测试主要遵循AIR原则,即自动化 (Automation)、独立性 (Independence)、 可重复性 (Repeatability);
编写单元测试时,应遵循以下原则(详细),以确保测试代码的质量和有效性:
1. 独立性 (Independence)
- 每个单元测试应当独立执行,互不依赖。
- 测试之间不应该共享状态或数据,以避免相互影响。
- 确保测试顺序不会影响测试结果。
2. 可重复性 (Repeatability)
- 测试应该在任何环境下都能产生一致的结果。
- 避免依赖外部系统(如数据库、网络服务等),可以使用模拟对象(mock objects)来隔离外部依赖。
3. 自动化 (Automation)
- 单元测试应当是自动化的,不需要人为干预。
- 能够通过持续集成工具自动运行。
4. 快速执行 (Fast Execution)
- 单元测试应当执行迅速,以便频繁运行。
- 测试时间过长会影响开发效率。
5. 可读性 (Readability)
- 测试代码应当易于阅读和理解,清晰表明测试目的。
- 测试方法和变量命名应具有描述性。
- 保持测试代码简单明了。
6. 覆盖面 (Coverage)
- 尽可能覆盖所有可能的代码路径和逻辑分支。
- 确保主要功能、边界条件和异常处理都被测试。
7. 明确的断言 (Clear Assertions)
- 每个测试应该有明确的断言,验证预期的结果。
- 使用合适的断言方法来检查输出。
8. 单一职责 (Single Responsibility)
- 每个测试方法应该只测试一个特定的行为或功能。
- 避免一个测试方法内包含多个测试点。
9. 隔离外部依赖 (Isolation of External Dependencies)
- 使用模拟对象、桩对象(stub)或虚拟服务(fake service)来隔离外部依赖。
- 避免实际的数据库操作、网络调用等。
10. 保持一致的测试结构 (Consistent Test Structure)
- 使用一致的测试命名约定和结构。
- 常见结构包括:设置(Setup)、执行(Execution)、验证(Verification)、清理(Teardown)。
示例:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
class SomeServiceTest {
@Mock
private Dependency dependency;
@InjectMocks
private SomeService someService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void testSomeFunctionality() {
// Setup
when(dependency.someMethod()).thenReturn("mocked response");
// Execution
String result = someService.callDependency();
// Verification
assertEquals("expected response", result);
}
}
在这个示例中:
- 独立性:每个测试方法独立执行。
- 可重复性:使用 Mockito 模拟对象,避免依赖外部系统。
- 可读性:方法和变量命名清晰明了。
- 单一职责:每个测试方法只测试一个功能。
遵循这些原则,可以编写出高质量的单元测试,确保代码的可靠性和可维护性。
Controller层的单元测试例子
代码
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.zou.ganttdiary.common.response.WebResponse;
import com.zou.ganttdiary.entity.RecordInfo;
import com.zou.ganttdiary.entity.dto.DayAddDTO;
import com.zou.ganttdiary.entity.dto.RecordInfoPageQueryDTO;
import com.zou.ganttdiary.entity.dto.SaveOrUpdateItemInfoDTO;
import com.zou.ganttdiary.service.RecordApplicationService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 结合了事项表的应用controller
*/
@Api(tags = "记录应用控制层")
@RestController
@RequestMapping("recordInfo")
public class RecordApplicationController {
@Resource
private RecordApplicationService recordApplicationService;
@ApiOperation("新增一条记录")
@PostMapping("/add")
public WebResponse<Long> add(@RequestBody DayAddDTO dto){
return WebResponse.ok(recordApplicationService.addDay(dto));
}
@ApiOperation("新增或编辑记录的事项")
@PostMapping("/saveOrUpdateItemInfo")
public WebResponse<Boolean> saveOrUpdateItemInfo(@RequestBody SaveOrUpdateItemInfoDTO dto){
return WebResponse.ok(recordApplicationService.saveOrUpdateItemInfo(dto));
}
@ApiOperation("分页查询记录")
@PostMapping("page")
public WebResponse<IPage<RecordInfo>> page(@RequestBody RecordInfoPageQueryDTO dto){
return WebResponse.ok(recordApplicationService.page(dto));
}
@ApiOperation("删除一条记录")
@DeleteMapping("delete/{id}")
public WebResponse<Boolean> deleteRecordInfoById(@PathVariable Long id){
return WebResponse.ok(recordApplicationService.deleteRecordInfoById(id));
}
}
单元测试
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.zou.ganttdiary.entity.RecordInfo;
import com.zou.ganttdiary.entity.dto.DayAddDTO;
import com.zou.ganttdiary.entity.dto.RecordInfoPageQueryDTO;
import com.zou.ganttdiary.entity.dto.SaveOrUpdateItemInfoDTO;
import com.zou.ganttdiary.service.RecordApplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Collections;
import java.util.Date;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
class RecordApplicationControllerTest {
private MockMvc mockMvc;
@Mock
private RecordApplicationService recordApplicationService;
@InjectMocks
private RecordApplicationController recordApplicationController;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
mockMvc = MockMvcBuilders.standaloneSetup(recordApplicationController).build();
}
@Test
void testAdd() throws Exception {
DayAddDTO dto = new DayAddDTO();
dto.setProjectId(0L);
dto.setRecordDate(new Date());
when(recordApplicationService.addDay(any(DayAddDTO.class))).thenReturn(1L);
mockMvc.perform(post("/recordInfo/add")
.contentType(MediaType.APPLICATION_JSON)
.content(JSON.toJSONString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.body").value(1L));
}
@Test
void testSaveOrUpdateItemInfo() throws Exception {
SaveOrUpdateItemInfoDTO dto = new SaveOrUpdateItemInfoDTO();
when(recordApplicationService.saveOrUpdateItemInfo(any(SaveOrUpdateItemInfoDTO.class))).thenReturn(true);
mockMvc.perform(post("/recordInfo/saveOrUpdateItemInfo")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"someField\":\"someValue\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.body").value(true));
}
@Test
void testPage() throws Exception {
RecordInfoPageQueryDTO dto = new RecordInfoPageQueryDTO();
// 创建具体的 IPage 实现对象
IPage<RecordInfo> page = new Page<>();
page.setRecords(Collections.singletonList(new RecordInfo())); // 添加记录
page.setTotal(1);
page.setCurrent(1);
page.setSize(10);
when(recordApplicationService.page(any(RecordInfoPageQueryDTO.class))).thenReturn(page);
mockMvc.perform(post("/recordInfo/page")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"someField\":\"someValue\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.body").exists());
}
@Test
void testDeleteRecordInfoById() throws Exception {
when(recordApplicationService.deleteRecordInfoById(1L)).thenReturn(true);
mockMvc.perform(delete("/recordInfo/delete/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.body").value(true));
}
}
Service层的单元测试例子
代码
import cn.hutool.core.bean.BeanUtil;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.zou.ganttdiary.entity.Day;
import com.zou.ganttdiary.entity.Item;
import com.zou.ganttdiary.entity.ItemInfo;
import com.zou.ganttdiary.entity.RecordInfo;
import com.zou.ganttdiary.entity.dto.DayAddDTO;
import com.zou.ganttdiary.entity.dto.RecordInfoPageQueryDTO;
import com.zou.ganttdiary.entity.dto.SaveOrUpdateItemInfoDTO;
import com.zou.ganttdiary.entity.vo.DayVO;
import com.zou.ganttdiary.service.DayService;
import com.zou.ganttdiary.service.ItemService;
import com.zou.ganttdiary.service.RecordApplicationService;
import com.zou.ganttdiary.wrapper.RecordInfoWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.sql.Wrapper;
import java.util.*;
import java.util.stream.Collectors;
@Service("recordApplicationService")
public class RecordApplicationServiceImpl implements RecordApplicationService {
@Resource
private DayService dayService;
@Resource
private ItemService itemService;
@Override
public Long addDay(DayAddDTO dto) {
Day day = new Day();
day.setProjectId(dto.getProjectId());
day.setRecordDate(dto.getRecordDate());
Day addRes = dayService.add(day);
return addRes.getId();
}
@Override
public IPage<RecordInfo> page(RecordInfoPageQueryDTO dto) {
IPage<DayVO> dayPage;
if(StringUtils.hasText(dto.getKeyword())){
// 关键字分页查询
List<Item> itemList = itemService.queryByKeyword(dto.getKeyword());
List<Long> dayIds = itemList.stream().map(Item::getDayId).distinct().collect(Collectors.toList());
dayPage = dayService.page(new Page<>(dto.getPage(), dto.getPageSize()), new QueryWrapper<Day>().in("id", dayIds)).convert(item -> BeanUtil.copyProperties(item, DayVO.class));;
}else{
// 正常分页查询
dayPage = dayService.queryByPage(DayVO.builder()
.page(dto.getPage())
.pageSize(dto.getPageSize())
.id(dto.getId())
.build());
}
// 关联查询item
List<Long> dayIds = dayPage.getRecords().stream().map(DayVO::getId).collect(Collectors.toList());
// k: dayId, v: List<Item>
Map<Long, List<Item>> map = itemService.getByDayIds(dayIds).stream().collect(Collectors.groupingBy(Item::getDayId));
// 处理返回的数据结构
List<RecordInfo> res = RecordInfoWrapper.build(dayPage.getRecords(), map);
IPage<RecordInfo> pageResult = new Page<>();
BeanUtil.copyProperties(dayPage, pageResult);
pageResult.setRecords(res);
return pageResult;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean saveOrUpdateItemInfo(SaveOrUpdateItemInfoDTO dto) {
List<ItemInfo> itemInfoList = dto.getItemInfoList();
// 时长求和
int totalDuration = itemInfoList.parallelStream().mapToInt(ItemInfo::getDuration).sum();
Day day = dayService.getById(dto.getDayId());
day.setTotalDuration(totalDuration);
List<Item> itemList = itemInfoList.parallelStream().map(itemInfo -> {
Item item = new Item();
item.setId(itemInfo.getItemId());
item.setDayId(itemInfo.getDayId());
item.setContent(itemInfo.getContent());
item.setTimeJson(JSON.toJSONString(itemInfo.getTimeRecord()));
item.setDuration(itemInfo.getDuration());
return item;
}).collect(Collectors.toList());
// 删除
if(!Objects.isNull(dto.getDeletedItemIds())){
dto.getDeletedItemIds().forEach(item -> itemService.deleteById(item));
}
// 更新
return itemService.saveOrUpdateBatch(itemList) && dayService.saveOrUpdate(day);
}
@Override
public Boolean deleteRecordInfoById(Long id) {
List<Item> itemList = itemService.getByDayIds(Collections.singletonList(id));
if(!itemList.isEmpty()){
throw new IllegalStateException("该记录下还有事项,无法删除");
}
return dayService.deleteById(id);
}
}
单元测试
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.zou.ganttdiary.entity.Day;
import com.zou.ganttdiary.entity.Item;
import com.zou.ganttdiary.entity.ItemInfo;
import com.zou.ganttdiary.entity.RecordInfo;
import com.zou.ganttdiary.entity.dto.DayAddDTO;
import com.zou.ganttdiary.entity.dto.RecordInfoPageQueryDTO;
import com.zou.ganttdiary.entity.dto.SaveOrUpdateItemInfoDTO;
import com.zou.ganttdiary.entity.vo.DayVO;
import com.zou.ganttdiary.service.DayService;
import com.zou.ganttdiary.service.ItemService;
import lombok.SneakyThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
public class RecordApplicationServiceImplTest {
@Mock
private DayService dayService;
@Mock
private ItemService itemService;
@InjectMocks
private RecordApplicationServiceImpl recordApplicationService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@SneakyThrows
@Test
void testAddDay() {
DayAddDTO dto = new DayAddDTO();
dto.setProjectId(1L);
String dateString = "2024-07-28";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
Date date = dateFormat.parse(dateString);
dto.setRecordDate(date);
Day day = new Day();
day.setId(1L);
day.setProjectId(1L);
day.setRecordDate(date);
when(dayService.add(any(Day.class))).thenReturn(day);
Long result = recordApplicationService.addDay(dto);
assertEquals(1L, result);
}
@Test
void testPage() {
RecordInfoPageQueryDTO dto = new RecordInfoPageQueryDTO();
dto.setPage(1);
dto.setPageSize(10);
dto.setKeyword("test");
Item item = new Item();
item.setDayId(1L);
List<Item> itemList = Collections.singletonList(item);
when(itemService.queryByKeyword(anyString())).thenReturn(itemList);
Day day = new Day();
day.setId(1L);
Page<DayVO> dayPage = new Page<>();
dayPage.setRecords(Collections.singletonList(new DayVO()));
when(dayService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(dayPage);
when(itemService.getByDayIds(anyList())).thenReturn(itemList);
IPage<RecordInfo> result = recordApplicationService.page(dto);
assertEquals(1, result.getRecords().size());
}
@Test
void testSaveOrUpdateItemInfo() {
SaveOrUpdateItemInfoDTO dto = new SaveOrUpdateItemInfoDTO();
dto.setDayId(1L);
ItemInfo itemInfo = new ItemInfo();
itemInfo.setDuration(60);
itemInfo.setItemId(1L);
dto.setItemInfoList(Collections.singletonList(itemInfo));
Day day = new Day();
day.setId(1L);
when(dayService.getById(anyLong())).thenReturn(day);
when(itemService.saveOrUpdateBatch(anyList())).thenReturn(true);
when(dayService.saveOrUpdate(any(Day.class))).thenReturn(true);
Boolean result = recordApplicationService.saveOrUpdateItemInfo(dto);
assertTrue(result);
}
@Test
void testDeleteRecordInfoById() {
Long id = 1L;
when(itemService.getByDayIds(anyList())).thenReturn(Collections.emptyList());
when(dayService.deleteById(anyLong())).thenReturn(true);
Boolean result = recordApplicationService.deleteRecordInfoById(id);
assertTrue(result);
}
@Test
void testDeleteRecordInfoByIdThrowsException() {
Long id = 1L;
Item item = new Item();
item.setDayId(1L);
when(itemService.getByDayIds(anyList())).thenReturn(Collections.singletonList(item));
try {
recordApplicationService.deleteRecordInfoById(id);
} catch (IllegalStateException e) {
assertEquals("该记录下还有事项,无法删除", e.getMessage());
}
}
}