Files
openclaw-config/workspace-planner/docs/单元测试规范.md
T

13 KiB

mica 项目单元测试规范

1. 测试框架与依赖

1.1 核心依赖

<!-- Spring Boot Test (包含 JUnit 5, Mockito, AssertJ) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- Mockito Inline (支持静态方法 mock) -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <version>4.6.1</version>
    <scope>test</scope>
</dependency>

1.2 测试目录结构

src/test/
├── java/
│   └── com/witsoft/mica/
│       ├── smd/
│       │   ├── controller/
│       │   │   └── ItemControllerTest.java
│       │   ├── service/
│       │   │   └── ItemServiceTest.java
│       │   └── mapper/
│       │       └── ItemMapperTest.java
│       └── ...
└── resources/
    └── application-test.yml

2. Controller 层测试规范

2.1 测试类结构

package com.witsoft.mica.smd.controller;

import com.witsoft.gen.base.common.ResponseModel;
import com.witsoft.gen.base.page.PageDomain;
import com.witsoft.mica.smd.dto.ItemQueryDTO;
import com.witsoft.mica.smd.service.ItemService;
import com.witsoft.mica.smd.vo.ItemVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("物料信息管理 Controller 测试")
class ItemControllerTest {

    @Mock
    private ItemService itemService;

    @InjectMocks
    private ItemController itemController;

    private ItemQueryDTO queryDTO;
    private PageDomain<ItemVO> pageDomain;

    @BeforeEach
    void setUp() {
        // 准备测试数据
        queryDTO = new ItemQueryDTO();
        queryDTO.setPageNo(1);
        queryDTO.setPageSize(10);
        queryDTO.setEcid("test-ecid-001");

        pageDomain = new PageDomain<>(1, 10, 1);
        List<ItemVO> list = new ArrayList<>();
        ItemVO itemVO = new ItemVO();
        itemVO.setId("1");
        itemVO.setItemCode("ITEM001");
        list.add(itemVO);
        pageDomain.setList(list);
    }

    @Test
    @DisplayName("分页查询 - 成功")
    void testQueryPageListSuccess() {
        // Arrange
        when(itemService.queryPageList(any(ItemQueryDTO.class))).thenReturn(pageDomain);

        // Act
        ResponseModel<PageDomain<ItemVO>> response = itemController.queryPageList(queryDTO);

        // Assert
        assertNotNull(response);
        assertTrue(response.isSuccess());
        assertNotNull(response.getData());
        assertEquals(1, response.getData().getTotal());

        verify(itemService, times(1)).queryPageList(any(ItemQueryDTO.class));
    }
}

2.2 测试要点

必须测试的场景

  1. 成功场景 - 正常业务流程
  2. 参数校验 - 空值、非法值处理
  3. 异常处理 - Service 层异常捕获
  4. 边界条件 - 空列表、null 值

⚠️ 项目特殊挑战

由于项目使用了 GlobalUtils.getEcid()ResponseModel.succeed() 等依赖 Spring 上下文的静态方法,纯单元测试会遇到困难。

解决方案:

方案 A:集成测试(推荐)

@SpringBootTest
@AutoConfigureMockMvc
class ItemControllerIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void testQueryPageList() throws Exception {
        mockMvc.perform(post("/web/itemInfo/getPageList")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"pageNo\":1,\"pageSize\":10}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true));
    }
}

方案 B:Service 层单元测试(优先)

  • Controller 层逻辑简单,主要通过集成测试覆盖
  • Service 层使用 Mockito 进行纯单元测试

3. Service 层测试规范

3.1 测试类结构

package com.witsoft.mica.smd.service;

import com.witsoft.gen.base.page.PageDomain;
import com.witsoft.mica.smd.dto.ItemQueryDTO;
import com.witsoft.mica.smd.mapper.ItemMapper;
import com.witsoft.mica.smd.vo.ItemVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("物料信息服务测试")
class ItemServiceTest {

    @Mock
    private ItemMapper itemMapper;

    @InjectMocks
    private ItemServiceImpl itemService;

    private ItemQueryDTO queryDTO;
    private List<ItemVO> mockList;

    @BeforeEach
    void setUp() {
        queryDTO = new ItemQueryDTO();
        queryDTO.setPageNo(1);
        queryDTO.setPageSize(10);

        mockList = new ArrayList<>();
        ItemVO item = new ItemVO();
        item.setId("1");
        item.setItemCode("ITEM001");
        mockList.add(item);
    }

    @Test
    @DisplayName("分页查询 - 成功")
    void testQueryPageListSuccess() {
        // Arrange
        when(itemMapper.queryPageCount(any(ItemQueryDTO.class))).thenReturn(1L);
        when(itemMapper.queryPageList(any(ItemQueryDTO.class))).thenReturn(mockList);

        // Act
        PageDomain<ItemVO> result = itemService.queryPageList(queryDTO);

        // Assert
        assertNotNull(result);
        assertEquals(1, result.getTotal());
        assertEquals(1, result.getList().size());
        assertEquals("ITEM001", result.getList().get(0).getItemCode());

        verify(itemMapper, times(1)).queryPageCount(any(ItemQueryDTO.class));
        verify(itemMapper, times(1)).queryPageList(any(ItemQueryDTO.class));
    }

    @Test
    @DisplayName("分页查询 - 无数据")
    void testQueryPageListEmpty() {
        // Arrange
        when(itemMapper.queryPageCount(any(ItemQueryDTO.class))).thenReturn(0L);
        when(itemMapper.queryPageList(any(ItemQueryDTO.class))).thenReturn(new ArrayList<>());

        // Act
        PageDomain<ItemVO> result = itemService.queryPageList(queryDTO);

        // Assert
        assertNotNull(result);
        assertEquals(0, result.getTotal());
        assertTrue(result.getList().isEmpty());
    }

    @Test
    @DisplayName("详情查询 - 成功")
    void testQueryByIdSuccess() {
        // Arrange
        String testId = "123";
        ItemVO mockItem = new ItemVO();
        mockItem.setId(testId);
        mockItem.setItemCode("ITEM001");

        when(itemMapper.selectById(testId)).thenReturn(mockItem);

        // Act
        ItemVO result = itemService.queryById(testId);

        // Assert
        assertNotNull(result);
        assertEquals(testId, result.getId());
        assertEquals("ITEM001", result.getItemCode());
    }

    @Test
    @DisplayName("详情查询 - 不存在")
    void testQueryByIdNotFound() {
        // Arrange
        when(itemMapper.selectById("not-exist")).thenReturn(null);

        // Act
        ItemVO result = itemService.queryById("not-exist");

        // Assert
        assertNull(result);
    }
}

3.2 Feign 调用测试

@Test
@DisplayName("新增物料 - Feign 调用成功")
void testInsertItemSuccess() {
    // Arrange
    ItemFormDTO dto = new ItemFormDTO();
    dto.setItemCode("ITEM001");
    dto.setItemName("测试物料");

    ResponseModel mockResponse = ResponseModel.succeed(null);
    when(itemApi.insertItem(any(Map.class))).thenReturn(mockResponse);

    // Act
    ResponseModel result = itemService.insertItem(dto);

    // Assert
    assertNotNull(result);
    assertTrue(result.isSuccess());

    verify(itemApi, times(1)).insertItem(any(Map.class));
}

4. Mapper 层测试规范

4.1 使用 MyBatis Spring Boot Test

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter-test</artifactId>
    <version>2.3.1</version>
    <scope>test</scope>
</dependency>

4.2 测试类结构

package com.witsoft.mica.smd.mapper;

import com.witsoft.mica.smd.dto.ItemQueryDTO;
import com.witsoft.mica.smd.vo.ItemVO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class ItemMapperTest {

    @Autowired
    private ItemMapper itemMapper;

    @Test
    void testQueryPageList() {
        // Arrange
        ItemQueryDTO dto = new ItemQueryDTO();
        dto.setPageNo(1);
        dto.setPageSize(10);
        dto.setEcid("test-ecid");

        // Act
        List<ItemVO> list = itemMapper.queryPageList(dto);
        long total = itemMapper.queryPageCount(dto);

        // Assert
        assertNotNull(list);
        assertTrue(total >= 0);
    }

    @Test
    void testSelectById() {
        // Act
        ItemVO item = itemMapper.selectById("1");

        // Assert
        assertNotNull(item);
        assertNotNull(item.getItemCode());
    }
}

5. 测试命名规范

5.1 测试类命名

{被测试类名}Test.java
示例:ItemControllerTest.java, ItemServiceTest.java

5.2 测试方法命名

test{方法名}{场景}.java
示例:
- testQueryPageListSuccess()
- testQueryPageListWithEmptyParams()
- testQueryPageListServiceException()
- testInsertItemValidationFailed()

5.3 DisplayName 注解

@DisplayName("分页查询 - 成功")
@DisplayName("分页查询 - 参数为空时返回空结果")
@DisplayName("分页查询 - 服务异常处理")
@DisplayName("新增物料 - 参数校验失败")

6. 断言规范

6.1 常用断言

// 非空断言
assertNotNull(result);
assertNull(result);

// 布尔值断言
assertTrue(response.isSuccess());
assertFalse(response.isSuccess());

// 相等断言
assertEquals(1, result.getTotal());
assertEquals("ITEM001", result.getItemCode());

// 集合断言
assertTrue(list.isEmpty());
assertEquals(1, list.size());

// 异常断言
assertThrows(Exception.class, () -> {
    itemController.deleteItem("");
});

6.2 Mockito 验证

// 验证方法调用次数
verify(itemService, times(1)).queryPageList(any());
verify(itemService, never()).deleteItem(any());

// 验证调用参数
verify(itemService).insertItem(argThat(dto -> 
    dto.getItemCode() != null && 
    dto.getEcid() != null
));

7. 测试覆盖率要求

层级 覆盖率要求 优先级
Service ≥ 80%
Controller ≥ 70%
Mapper ≥ 60%
Entity/DTO 不要求

8. 测试执行命令

# 运行所有测试
mvn test

# 运行指定测试类
mvn test -Dtest=ItemControllerTest

# 运行指定测试方法
mvn test -Dtest=ItemControllerTest#testQueryPageListSuccess

# 生成覆盖率报告
mvn clean test jacoco:report

9. 最佳实践

DO

  1. 测试独立 - 每个测试方法相互独立,不依赖执行顺序
  2. 命名清晰 - 测试方法名清晰表达测试意图
  3. Arrange-Act-Assert - 遵循 AAA 模式组织测试代码
  4. 测试边界 - 重点测试边界条件和异常情况
  5. Mock 外部依赖 - 使用 Mockito 隔离外部依赖

DON'T

  1. 不要测试私有方法 - 通过公共方法间接测试
  2. 不要过度测试 - 聚焦业务逻辑,不测试 getter/setter
  3. 不要依赖外部状态 - 每个测试自包含,不依赖数据库真实数据
  4. 不要忽略失败 - 测试失败必须修复或合理解释

10. 示例:完整测试类

参考 ItemControllerTest.javaItemServiceTest.java


11. 持续集成

Jenkins Pipeline 配置

pipeline {
    agent any
    stages {
        stage('Test') {
            steps {
                sh 'mvn clean test'
            }
            post {
                always {
                    junit 'target/surefire-reports/*.xml'
                }
            }
        }
        stage('Coverage') {
            steps {
                sh 'mvn jacoco:report'
            }
            post {
                always {
                    jacoco execPattern: 'target/jacoco.exec'
                }
            }
        }
    }
}

12. 参考资料