Spring Boot + JUnit 5 + Mockito + JaCoCo 单元测试实战指南

从0到1搭建测试环境,避开常见坑


一、为什么需要单元测试?

单元测试就像软件开发的"安全带",能帮你:

  • ✅ 快速发现问题(代码修改后立刻知道哪里出错)
  • ✅ 提高代码质量(强制思考边界条件)
  • ✅ 加快开发速度(测试失败时不会慌乱)
  • ✅ 文档化功能(测试代码本身就是真实用法示例)

二、你需要准备什么工具?

工具 作用 为什么需要
JUnit 5 测试框架 Java官方推荐,支持最新特性
Mockito 模拟对象 替代数据库/网络调用,快速测试
JaCoCo 覆盖率报告 查看代码被测试了多少
Spring Boot Test 集成测试 提供便捷的测试工具

三、环境搭建步骤

1. 创建Spring Boot项目

使用 Spring Initializr 选择以下依赖:

  • Spring Web
  • Spring Data JPA
  • Lombok
  • Spring Boot DevTools

2. 添加测试依赖(pom.xml

主要测试依赖如下:

XML 复制代码
<!-- 核心测试包(包含JUnit5+Mockito) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- 重要!排除旧版JUnit4(提升测试速度) -->
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 可选:如果需要模拟final类/静态方法才添加 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>

<!-- JaCoCo 覆盖率插件 -->
<plugin>
	<groupId>org.jacoco</groupId>
	<artifactId>jacoco-maven-plugin</artifactId>
	<version>0.8.13</version>
	<executions>
		<!-- 准备JaCoCo运行时代理 -->
		<execution>
			<id>prepare-agent</id>
			<goals>
				<goal>prepare-agent</goal>
			</goals>
		</execution>
		<!-- 生成覆盖率报告 -->
		<execution>
			<id>report</id>
			<phase>test</phase>
			<goals>
				<goal>report</goal>
			</goals>
			<configuration>
				<outputDirectory>${project.build.directory}/site/jacoco</outputDirectory>
				 <includes>
					 <!--只包含xx目录下的bean,改成你自己的-->
					 <include>com/chinaunicom/medical/ihm/supply/server/service/*.class</include>
					 <include>com/chinaunicom/medical/ihm/supply/server/controller/*.class</include>
				 </includes>
			</configuration>
		</execution>
		<!-- 创建聚合报告 -->
		<execution>
			<id>report-aggregate</id>
			<phase>test</phase> <!-- 在test阶段执行 -->
			<goals>
				<goal>report-aggregate</goal>
			</goals>
			<configuration>
				<outputDirectory>${project.build.directory}/site/jacoco-aggregate</outputDirectory>
			</configuration>
		</execution>
	</executions>
</plugin>            

在真实场景中,xml配置文件如下:

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.2</version>
        <!-- lookup parent from repository -->
    </parent>
    <groupId>com.chinaunicom.medical</groupId>
    <artifactId>chinaunicom-medical-ihm-middle-platform</artifactId>
    <version>${revision}</version>
    <name>chinaunicom-medical-ihm-middile-platform</name>
    <description>互联网医院</description>
    <packaging>pom</packaging>
    <modules>
        <module>applications/chinaunicom-medical-ihm-supply-server</module>
    </modules>
    <properties>
        <!-- 当前项目的版本 用了flatten 仅修改这一个地方即可-->
        <revision>2.1.0</revision>
        <java.version>17</java.version>
        <project.build.outputTimestamp>2023-01-01T00:00:00Z</project.build.outputTimestamp>
        <!-- 暂时保持不变的 3.20.0-->
        <redisson-spring-boot-starter.version>3.20.0</redisson-spring-boot-starter.version>

        <transmittable-thread-local.version>2.12.1</transmittable-thread-local.version>
        <hutool-all.version>5.8.20</hutool-all.version>
        <guava.version>31.1-jre</guava.version>
        <jxls.version>3.0.0</jxls.version>
        <lombok.version>1.18.22</lombok.version>
        <okhttp3.version>4.10.0</okhttp3.version>
        <!-- mybatis相关 3.5.3.1-->
        <baomidou.mybatis-plus.version>3.5.3.1</baomidou.mybatis-plus.version>
        <mybatis.version>3.5.10</mybatis.version>

        <!-- spring-boot,spring-cloud,spring-cloud-alibaba依赖版本 -->
        <spring.boot.version>3.0.2</spring.boot.version>
        <springframework.cloud.version>2022.0.0</springframework.cloud.version>
        <!--参考nacos官方的版本关系 https://github.com/alibaba/spring-cloud-alibaba/wiki/%E7%89%88%E6%9C%AC%E8%AF%B4%E6%98%8E-->
        <alibaba.cloud.version>2022.0.0.0-RC2</alibaba.cloud.version>

        <jasypt.spring.boot.starte.version>3.0.5</jasypt.spring.boot.starte.version>

        <spring-cloud-starter-sleuth.version>3.1.1</spring-cloud-starter-sleuth.version>
        <!-- alibaba -->
        <!--        <nacos-client.version>2.2.3</nacos-client.version>-->

        <!-- feign -->
        <feign-form.version>3.8.0</feign-form.version>

        <!-- swagger -->
        <springdoc-openapi.version>2.0.2</springdoc-openapi.version>
        <knife4j-springdoc-ui.version>3.0.3</knife4j-springdoc-ui.version>
        <knife4j-openapi3-jakarta.version>4.3.0</knife4j-openapi3-jakarta.version>
        <jakarta.annotation-api.version>2.1.1</jakarta.annotation-api.version>
        <jakarta.validation.version>3.0.2</jakarta.validation.version>
        <jakarta.servlet-api.version>5.0.0</jakarta.servlet-api.version>
        <commons-io.version>2.16.1</commons-io.version>
        <!--单元测试-->
        <mockito-inline.version>5.2.0</mockito-inline.version>

    </properties>


    <dependencyManagement>
        <dependencies>
            <!--    spring     -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring.boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${springframework.cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!--    alibaba     -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${alibaba.cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>


            <!--    tools     -->
            <dependency>
                <groupId>cn.hutool</groupId>
                <artifactId>hutool-all</artifactId>
                <version>${hutool-all.version}</version>
            </dependency>

        
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
            </dependency>
            <!--    mybatis     -->
            <dependency>
                <groupId>org.mybatis</groupId>
                <artifactId>mybatis</artifactId>
                <version>${mybatis.version}</version>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${baomidou.mybatis-plus.version}</version>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-extension</artifactId>
                <version>${baomidou.mybatis-plus.version}</version>
                <scope>compile</scope>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-generator</artifactId>
                <version>${baomidou.mybatis-plus.version}</version>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-annotation</artifactId>
                <version>${baomidou.mybatis-plus.version}</version>
            </dependency>
            <!-- redisson -->
            <dependency>
                <groupId>org.redisson</groupId>
                <artifactId>redisson-spring-boot-starter</artifactId>
                <version>${redisson-spring-boot-starter.version}</version>
            </dependency>
            <dependency>
                <groupId>org.apache.logging.log4j</groupId>
                <artifactId>log4j-to-slf4j</artifactId>
                <version>2.19.0</version>
            </dependency>

            <!--doc -->
            <dependency>
                <groupId>org.springdoc</groupId>
                <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
                <version>${springdoc-openapi.version}</version>
            </dependency>
            <dependency>
                <groupId>com.squareup.okhttp3</groupId>
                <artifactId>okhttp</artifactId>
                <version>${okhttp3.version}</version>
            </dependency>



            <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
            <dependency>
                <groupId>commons-io</groupId>
                <artifactId>commons-io</artifactId>
                <version>${commons-io.version}</version>
            </dependency>

            <dependency>
                <groupId>com.github.xiaoymin</groupId>
                <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
                <version>${knife4j-openapi3-jakarta.version}</version>
            </dependency>

            <!-- 核心测试包(包含JUnit5+Mockito) -->
			<dependency>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-starter-test</artifactId>
				<scope>test</scope>
				<!-- 重要!排除旧版JUnit4(提升测试速度) -->
				<exclusions>
					<exclusion>
						<groupId>org.junit.vintage</groupId>
						<artifactId>junit-vintage-engine</artifactId>
					</exclusion>
				</exclusions>
			</dependency>
            <!-- 仅当需要 mock final 类/静态方法时添加 -->
            <dependency>
                <groupId>org.mockito</groupId>
                <artifactId>mockito-inline</artifactId>
                <version>${mockito-inline.version}</version> <!-- 或更高 -->
                <scope>test</scope>
            </dependency>
        </dependencies>
        <!--ihm-->
    </dependencyManagement>


    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                    <encoding>UTF-8</encoding>
                    <useIncrementalCompilation>true</useIncrementalCompilation>

                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-source-plugin</artifactId>
                <executions>
                    <execution>
                        <id>attach-sources</id>
                        <goals>
                            <goal>jar</goal> <!-- 生成源码包 -->
                        </goals>
                    </execution>
                </executions>
            </plugin>
			<!-- JaCoCo 覆盖率插件 -->
			<plugin>
				<groupId>org.jacoco</groupId>
				<artifactId>jacoco-maven-plugin</artifactId>
				<version>0.8.13</version>
				<executions>
					<!-- 准备JaCoCo运行时代理 -->
					<execution>
						<id>prepare-agent</id>
						<goals>
							<goal>prepare-agent</goal>
						</goals>
					</execution>
					<!-- 生成覆盖率报告 -->
					<execution>
						<id>report</id>
						<phase>test</phase>
						<goals>
							<goal>report</goal>
						</goals>
						<configuration>
							<outputDirectory>${project.build.directory}/site/jacoco</outputDirectory>
							 <includes>
								 <!--只包含xx目录下的bean,改成你自己的-->
								 <include>com/chinaunicom/medical/ihm/supply/server/service/*.class</include>
								 <include>com/chinaunicom/medical/ihm/supply/server/controller/*.class</include>
							 </includes>
						</configuration>
					</execution>
					<!-- 创建聚合报告 -->
					<execution>
						<id>report-aggregate</id>
						<phase>test</phase> <!-- 在test阶段执行 -->
						<goals>
							<goal>report-aggregate</goal>
						</goals>
						<configuration>
							<outputDirectory>${project.build.directory}/site/jacoco-aggregate</outputDirectory>
						</configuration>
					</execution>
				</executions>
			</plugin>            
        </plugins>

    </build>

</project>

四、核心测试写法(分场景)

✅ 场景1:纯单元测试(不启动Spring)

java 复制代码
@ExtendWith(MockitoExtension.class) // Mockito扩展
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository; // 模拟数据库操作
    
    @InjectMocks
    private UserService userService; // 被测对象
    
    @Test
    void 应该返回用户信息() {
        // 1. 设置模拟数据
        User mockUser = new User(1L, "张三");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
        
        // 2. 执行被测方法
        User result = userService.getUserById(1L);
        
        // 3. 验证结果
        assertEquals("张三", result.getName());
    }
}

✅ 场景2:Controller层测试(轻量级)

java 复制代码
@WebMvcTest(UserController.class) // 仅测试Controller层
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc; // 模拟HTTP请求
    
    @MockBean
    private UserService userService; // 模拟服务层
    
    @Test
    void 应该返回用户信息() throws Exception {
        // 1. 设置模拟数据
        when(userService.getUserById(1L)).thenReturn(new User(1L, "李四"));
        
        // 2. 发起模拟请求
        mockMvc.perform(get("/user/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.name").value("李四"));
    }
}

项目真实测试代码举例

java 复制代码
@Slf4j
@ExtendWith(MockitoExtension.class)
@DisplayName("StepInstExtController 单元测试")
class StepInstExtControllerTest {

    @Mock
    private StepInstExtService stepInstExtService;

    @InjectMocks
    private StepInstExtController stepInstExtController;

    @Mock
    private LambdaQueryChainWrapper<StepInstExt> lambdaQueryChainWrapper;

    @BeforeEach
    void setUp() {
        // 设置 lambdaQuery() 返回 mock 的链式调用对象
        when(stepInstExtService.lambdaQuery()).thenReturn(lambdaQueryChainWrapper);
        // 设置 eq() 方法返回自身,支持链式调用,使用 lenient 避免不必要的模拟异常
        lenient().when(lambdaQueryChainWrapper.eq(any(), any())).thenReturn(lambdaQueryChainWrapper);
    }

    @Test
    @DisplayName("正常场景:分页查询有数据")
    void testPage_Success_WithData() {
        // 准备测试数据
        Long workflowInstId = 1001L;
        Integer current = 1;
        Integer size = 10;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的分页数据
        Page<StepInstExt> pageResult = new Page<>(current, size);
        List<StepInstExt> records = new ArrayList<>();
        StepInstExt stepInstExt1 = new StepInstExt();
        stepInstExt1.setId(1L);
        stepInstExt1.setWorkflowInstId(workflowInstId);
        stepInstExt1.setState("pending");
        records.add(stepInstExt1);

        StepInstExt stepInstExt2 = new StepInstExt();
        stepInstExt2.setId(2L);
        stepInstExt2.setWorkflowInstId(workflowInstId);
        stepInstExt2.setState("approved");
        records.add(stepInstExt2);

        pageResult.setRecords(records);
        pageResult.setTotal(2L);

        // Mock page() 方法返回结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:正常场景 - 分页查询有数据 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, data: {}", result.isSucc(), result.getData());
        if (result.getData() != null) {
            log.info("输出结果 - total: {}, records size: {}",
                    result.getData().getTotal(), result.getData().getRecords().size());
            result.getData().getRecords().forEach(record ->
                log.info("输出结果 - record: id={}, workflowInstId={}, state={}",
                        record.getId(), record.getWorkflowInstId(), record.getState())
            );
        }
        log.info("================================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertNotNull(result.getData(), "分页数据不应为空");
        assertEquals(2L, result.getData().getTotal(), "总记录数应为2");
        assertEquals(2, result.getData().getRecords().size(), "记录数应为2");
        assertEquals(workflowInstId, result.getData().getRecords().get(0).getWorkflowInstId(),
                "第一条记录的workflowInstId应匹配");

        // 验证方法调用
        verify(stepInstExtService, times(1)).lambdaQuery();
        verify(lambdaQueryChainWrapper, times(1)).eq(any(), eq(workflowInstId));
        verify(lambdaQueryChainWrapper, times(1)).page(any(Page.class));
    }

    @Test
    @DisplayName("空结果场景:查询结果为空")
    void testPage_Success_EmptyResult() {
        // 准备测试数据
        Long workflowInstId = 2001L;
        Integer current = 1;
        Integer size = 10;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的空分页数据
        Page<StepInstExt> pageResult = new Page<>(current, size);
        pageResult.setRecords(new ArrayList<>());
        pageResult.setTotal(0L);

        // Mock page() 方法返回空结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:空结果场景 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, data: {}", result.isSucc(), result.getData());
        if (result.getData() != null) {
            log.info("输出结果 - total: {}, records size: {}",
                    result.getData().getTotal(), result.getData().getRecords().size());
        }
        log.info("====================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertNotNull(result.getData(), "分页数据不应为空");
        assertEquals(0L, result.getData().getTotal(), "总记录数应为0");
        assertEquals(0, result.getData().getRecords().size(), "记录数应为0");

        // 验证方法调用
        verify(stepInstExtService, times(1)).lambdaQuery();
        verify(lambdaQueryChainWrapper, times(1)).eq(any(), eq(workflowInstId));
        verify(lambdaQueryChainWrapper, times(1)).page(any(Page.class));
    }

    @Test
    @DisplayName("边界值场景:第一页查询")
    void testPage_Boundary_FirstPage() {
        // 准备测试数据 - 第一页
        Long workflowInstId = 3001L;
        Integer current = 1;
        Integer size = 5;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的分页数据
        Page<StepInstExt> pageResult = new Page<>(current, size);
        List<StepInstExt> records = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            StepInstExt stepInstExt = new StepInstExt();
            stepInstExt.setId((long) i);
            stepInstExt.setWorkflowInstId(workflowInstId);
            stepInstExt.setState("state" + i);
            records.add(stepInstExt);
        }
        pageResult.setRecords(records);
        pageResult.setTotal(20L); // 总共20条数据

        // Mock page() 方法返回结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:边界值场景 - 第一页 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, total: {}, current page size: {}",
                result.isSucc(), result.getData().getTotal(), result.getData().getRecords().size());
        log.info("==============================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertEquals(20L, result.getData().getTotal(), "总记录数应为20");
        assertEquals(5, result.getData().getRecords().size(), "当前页记录数应为5");
        assertEquals(1, result.getData().getCurrent(), "当前页码应为1");
        assertEquals(5, result.getData().getSize(), "每页大小应为5");
    }

    @Test
    @DisplayName("边界值场景:最后一页查询")
    void testPage_Boundary_LastPage() {
        // 准备测试数据 - 最后一页(假设总共20条,每页10条,最后一页是第2页,只有3条数据)
        Long workflowInstId = 4001L;
        Integer current = 2;
        Integer size = 10;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的分页数据 - 最后一页只有3条
        Page<StepInstExt> pageResult = new Page<>(current, size);
        List<StepInstExt> records = new ArrayList<>();
        for (int i = 1; i <= 3; i++) {
            StepInstExt stepInstExt = new StepInstExt();
            stepInstExt.setId((long) (10 + i));
            stepInstExt.setWorkflowInstId(workflowInstId);
            stepInstExt.setState("state" + (10 + i));
            records.add(stepInstExt);
        }
        pageResult.setRecords(records);
        pageResult.setTotal(13L); // 总共13条数据

        // Mock page() 方法返回结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:边界值场景 - 最后一页 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, total: {}, current page size: {}, current page: {}",
                result.isSucc(), result.getData().getTotal(),
                result.getData().getRecords().size(), result.getData().getCurrent());
        log.info("================================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertEquals(13L, result.getData().getTotal(), "总记录数应为13");
        assertEquals(3, result.getData().getRecords().size(), "当前页记录数应为3");
        assertEquals(2, result.getData().getCurrent(), "当前页码应为2");
    }

    @Test
    @DisplayName("边界值场景:单条数据查询")
    void testPage_Boundary_SingleRecord() {
        // 准备测试数据 - 只有一条数据
        Long workflowInstId = 5001L;
        Integer current = 1;
        Integer size = 10;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的分页数据 - 只有一条
        Page<StepInstExt> pageResult = new Page<>(current, size);
        List<StepInstExt> records = new ArrayList<>();
        StepInstExt stepInstExt = new StepInstExt();
        stepInstExt.setId(1L);
        stepInstExt.setWorkflowInstId(workflowInstId);
        stepInstExt.setState("single");
        records.add(stepInstExt);
        pageResult.setRecords(records);
        pageResult.setTotal(1L);

        // Mock page() 方法返回结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:边界值场景 - 单条数据 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, total: {}, records: {}",
                result.isSucc(), result.getData().getTotal(), result.getData().getRecords());
        log.info("==============================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertEquals(1L, result.getData().getTotal(), "总记录数应为1");
        assertEquals(1, result.getData().getRecords().size(), "记录数应为1");
        assertEquals(workflowInstId, result.getData().getRecords().get(0).getWorkflowInstId(),
                "workflowInstId应匹配");
    }

    @Test
    @DisplayName("边界值场景:大分页大小")
    void testPage_Boundary_LargePageSize() {
        // 准备测试数据 - 大分页大小
        Long workflowInstId = 6001L;
        Integer current = 1;
        Integer size = 100;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // 准备返回的分页数据
        Page<StepInstExt> pageResult = new Page<>(current, size);
        List<StepInstExt> records = new ArrayList<>();
        for (int i = 1; i <= 50; i++) {
            StepInstExt stepInstExt = new StepInstExt();
            stepInstExt.setId((long) i);
            stepInstExt.setWorkflowInstId(workflowInstId);
            stepInstExt.setState("state" + i);
            records.add(stepInstExt);
        }
        pageResult.setRecords(records);
        pageResult.setTotal(50L);

        // Mock page() 方法返回结果
        when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

        // 打印输入参数
        log.info("========== 测试:边界值场景 - 大分页大小 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试
        Result<Page<StepInstExt>> result = stepInstExtController.page(param);

        // 打印输出结果
        log.info("输出结果 - success: {}, total: {}, current page size: {}",
                result.isSucc(), result.getData().getTotal(), result.getData().getRecords().size());
        log.info("================================================");

        // 验证结果
        assertNotNull(result, "返回结果不应为空");
        assertTrue(result.isSucc(), "应该返回成功结果");
        assertEquals(50L, result.getData().getTotal(), "总记录数应为50");
        assertEquals(50, result.getData().getRecords().size(), "当前页记录数应为50");
        assertEquals(100, result.getData().getSize(), "每页大小应为100");
    }

    @Test
    @DisplayName("异常场景:Service抛出异常")
    void testPage_Exception_ServiceThrowsException() {
        // 准备测试数据
        Long workflowInstId = 7001L;
        Integer current = 1;
        Integer size = 10;

        StepInstExtPageDto param = new StepInstExtPageDto();
        param.setWorkflowInstId(workflowInstId);
        param.setCurrent(current);
        param.setSize(size);

        // Mock service 抛出异常
        when(stepInstExtService.lambdaQuery()).thenThrow(new RuntimeException("数据库连接异常"));

        // 打印输入参数
        log.info("========== 测试:异常场景 - Service抛出异常 ==========");
        log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                param.getWorkflowInstId(), param.getCurrent(), param.getSize());

        // 执行测试并验证异常
        assertThrows(RuntimeException.class, () -> {
            stepInstExtController.page(param);
        }, "应该抛出RuntimeException");

        log.info("输出结果 - 异常已抛出:RuntimeException");
        log.info("==================================================");

        // 验证方法调用
        verify(stepInstExtService, times(1)).lambdaQuery();
    }

    @Test
    @DisplayName("参数验证:不同分页参数组合")
    void testPage_ParameterValidation_DifferentPageParams() {
        // 测试多组不同的分页参数
        Integer[][] testCases = {
            {1, 5},   // 第1页,每页5条
            {2, 10},  // 第2页,每页10条
            {3, 20},  // 第3页,每页20条
            {1, 1}    // 第1页,每页1条
        };

        for (Integer[] testCase : testCases) {
            Integer current = testCase[0];
            Integer size = testCase[1];
            Long workflowInstId = 8001L;

            StepInstExtPageDto param = new StepInstExtPageDto();
            param.setWorkflowInstId(workflowInstId);
            param.setCurrent(current);
            param.setSize(size);

            // 准备返回的分页数据
            Page<StepInstExt> pageResult = new Page<>(current, size);
            pageResult.setRecords(new ArrayList<>());
            pageResult.setTotal(0L);

            // Mock page() 方法返回结果
            when(lambdaQueryChainWrapper.page(any(Page.class))).thenReturn(pageResult);

            // 打印输入参数
            log.info("========== 测试:参数验证 - current={}, size={} ==========", current, size);
            log.info("输入参数 - workflowInstId: {}, current: {}, size: {}",
                    param.getWorkflowInstId(), param.getCurrent(), param.getSize());

            // 执行测试
            Result<Page<StepInstExt>> result = stepInstExtController.page(param);

            // 打印输出结果
            log.info("输出结果 - success: {}, current: {}, size: {}",
                    result.isSucc(), result.getData().getCurrent(), result.getData().getSize());
            log.info("========================================================");

            // 验证结果
            assertNotNull(result, "返回结果不应为空");
            assertTrue(result.isSucc(), "应该返回成功结果");
            assertEquals(current.longValue(), result.getData().getCurrent(), "当前页码应匹配");
            assertEquals(size.longValue(), result.getData().getSize(), "每页大小应匹配");
        }
    }
}

五、常见问题解答

1. 为什么排除junit-vintage-engine

  • 旧版兼容模块:允许运行JUnit4测试(老式测试写法)
  • 排除后的好处
    • 测试速度提升10-20%
    • 减少依赖体积
    • 避免混淆(只保留最新版)
  • 什么时候不要排除
    如果你还在用JUnit4写测试(不推荐)

2. 为什么不要启动Spring上下文?

  • 启动Spring容器需要:5-15秒(很慢!)
  • 单元测试应该:在毫秒级完成
  • 解决方案 :用@ExtendWith(MockitoExtension.class)代替

3. Mockito和SpringBootTest怎么选?

使用场景 推荐方式
测试业务逻辑(Service层) Mockito单元测试
测试接口(Controller层) @WebMvcTest
需要完整Spring环境 @SpringBootTest(慎用)

六、测试覆盖率报告(JaCoCo)

(1)生成报告命令

复制代码
mvn clean test

(2)查看报告路径

复制代码
target/site/jacoco/index.html

(3)目标覆盖率建议

  • 关键业务逻辑:100%覆盖
  • 日志/工具类:可接受低覆盖率

七、新手避坑指南

❌ 常见错误

  1. 滥用@SpringBootTest
    → 会导致测试变慢(建议90%用Mockito)
  2. 测试私有方法
    → 通过公共方法间接测试(私有方法属于实现细节)
  3. 忽略异常场景
    → 要测试空值、非法参数、异常抛出

✅ 正确姿势

  1. 测试命名规范

    java 复制代码
    // 好命名
    void shouldReturn400WhenEmailIsNull()
    
    // 差命名
    void test1()
  2. 遵循AAA模式

    java 复制代码
    // Arrange(准备)
    when(mockService.find()).thenReturn("mock");
    
    // Act(执行)
    String result = service.process();
    
    // Assert(断言)
    assertEquals("expected", result);

八、总结

步骤 做法 目标
1. 依赖配置 使用spring-boot-starter-test + 排除JUnit4 简化依赖
2. 单元测试 用Mockito模拟依赖 快速验证逻辑
3. 集成测试 用@WebMvcTest或@DataJpaTest 验证组件协作
4. 覆盖率 用JaCoCo生成报告 查看测试完整性

九、扩展建议

  • 测试金字塔 (推荐比例):

    javascript 复制代码
    单元测试(70%)→ 集成测试(20%)→ 端到端测试(10%)
  • 测试速度优化

    • 本地运行测试:100ms/用例
    • CI/CD运行测试:1-2秒/用例
相关推荐
原来是好奇心2 小时前
Spring源码深度解析(一):Spring的设计灵魂——控制反转与依赖注入的演进之路
java·spring·源码
艾莉丝努力练剑2 小时前
Al Ping免费上新:GLM-4.7 && MiniMaxM2.1重磅上线,附独家使用教程
java·大数据·linux·运维·人工智能·python
济南壹软网络科技有限公司2 小时前
IM源码架构深度解析:构建高并发、私有化的企业级通讯底座
java·架构·即时通讯源码·通讯im·企业级im
Knight_AL2 小时前
Java 可变参数 Object... args 详解:原理、用法与实战场景
java·开发语言·python
风月歌2 小时前
基于小程序的超市购物系统设计与实现源码(java+小程序+mysql+vue+文档)
java·mysql·微信小程序·小程序·毕业设计·源码
再来一根辣条2 小时前
Stream是怎么运行的?
java
C雨后彩虹2 小时前
幼儿园分班
java·数据结构·算法·华为·面试
黄俊懿2 小时前
【深入理解SpringCloud微服务】Gateway源码解析
java·后端·spring·spring cloud·微服务·gateway·架构师
悟能不能悟2 小时前
java list.addAll介绍
java·windows·list