单元测试编写

概述

记录,为了快速查找。

用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
        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());
        }
    }
}


你可能感兴趣的:(单元测试,log4j)