Skip to content

Spring Boot + Spring Cloud + MyBatis Plus 单元测试指南

约 2745 字大约 9 分钟

手册规范

2025-06-27

本文将详细介绍如何在 Spring Boot + Spring Cloud + MyBatis Plus 项目中分别为 DAO 层、Service 层和 Controller 层编写单元测试,并提供完整的测试案例。

  • DAO 层 :直接测试数据库操作,验证 SQL 和 MyBatis Plus 功能

  • Service 层 :使用 Mockito 模拟依赖,专注于业务逻辑测试

  • Controller 层 :使用 MockMvc 测试 HTTP 接口,验证请求响应流程

一、DAO 层单元测试

1. 测试目标

  • 验证 MyBatis Plus Mapper 接口的基本 CRUD 操作
  • 测试自定义 SQL 查询
  • 验证分页查询功能

2. 依赖配置

除了必要的mysql、mybatis plus、jdbc等依赖,还需添加如下测试相关依赖

springboot2版本

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
          	<version>1.9.3</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>5.7.0</version>
            <scope>test</scope>
        </dependency>

springboot3版本

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>

				<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>1.12.2</version>
            <scope>test</scope>
        </dependency>

3. 测试配置

测试数据源配置

src/test/java/com/yz/mall/sys/TestDataSourceConfig.java

package com.yz.mall.sys;

import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;

import javax.sql.DataSource;

/**
 * @author  yunze
 * @since  2025/7/10 18:53
 */
@Configuration
@MapperScan("com.yz.mall.sys.mapper")
public class TestDataSourceConfig {

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("classpath:init.sql")  // 初始化SQL脚本
                .build();
    }

    @Bean
    public MybatisSqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) {
        MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        return factoryBean;
    }

    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

src/test/java/com/yz/mall/sys/BaseMapperTest.java

注意:springboot3里面不需要使用@RunWith(SpringRunner.class)注解

package com.yz.mall.sys;

import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author yunze
 * @since 2025/7/10 18:52
 */
@RunWith(SpringRunner.class)
@SpringBootTest(classes = TestDataSourceConfig.class)
@TestPropertySource(properties = {
        "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
        "spring.datasource.driver-class-name=org.h2.Driver"
})
@Transactional
public class BaseMapperTest {
}

数据库表初始脚本src/test/resources/init.sql

注意,该脚本不知运行在mysql数据库里,而是H2这个测试专用的内存数据库里,测试结束后会自动销毁。

-- 创建表(去掉MySQL的COMMENT语法)
CREATE TABLE IF NOT EXISTS sys_user (
                                        id          BIGINT PRIMARY KEY NOT NULL,
                                        create_time TIMESTAMP,
                                        update_time TIMESTAMP,
                                        invalid     BIGINT DEFAULT 0,
                                        phone       VARCHAR(11) NOT NULL,
                                        email       VARCHAR(50),
                                        password    VARCHAR(255),
                                        balance     DECIMAL(15, 2) DEFAULT 0.00,
                                        username    VARCHAR(10) NOT NULL,
                                        status      INT DEFAULT 1 NOT NULL,
                                        avatar      VARCHAR(100),
                                        sex         INT DEFAULT 0 NOT NULL
);

-- 添加表注释
COMMENT ON TABLE sys_user IS '系统-用户表';

-- 添加列注释
COMMENT ON COLUMN sys_user.id IS '主键标识';
COMMENT ON COLUMN sys_user.create_time IS '创建时间';
COMMENT ON COLUMN sys_user.update_time IS '更新时间';
COMMENT ON COLUMN sys_user.invalid IS '数据是否有效:0数据有效';
COMMENT ON COLUMN sys_user.phone IS '手机号';
COMMENT ON COLUMN sys_user.email IS '邮箱';
COMMENT ON COLUMN sys_user.password IS '密码';
COMMENT ON COLUMN sys_user.balance IS '账户余额';
COMMENT ON COLUMN sys_user.username IS '昵称';
COMMENT ON COLUMN sys_user.status IS '状态1-启用,0-停用';
COMMENT ON COLUMN sys_user.avatar IS '头像';
COMMENT ON COLUMN sys_user.sex IS '状态1-女,0-男';

-- 创建唯一约束(H2使用CREATE INDEX语法)
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_user_email ON sys_user(email, invalid);
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_user_phone ON sys_user(invalid, phone);



INSERT INTO sys_user (id, create_time, update_time, invalid, phone, email, password, balance, username, status, avatar,
                      sex)
VALUES (1867495856688271360, '2024-12-13 17:04:43', '2025-04-26 22:31:19', 0, '15300000017', null, 'ABCdef123', 676.90,
        '小亮', 1, null, 0);

4. 测试案例

单元测试代码src/test/java/com/yz/mall/sys/mapper/SysUserMapperTest.java

package com.yz.mall.sys.mapper;

import com.yz.mall.sys.BaseMapperTest;
import com.yz.mall.sys.entity.SysUser;
import com.yz.mall.sys.vo.BaseUserVo;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author yunze
 * @since 2025/7/9 23:38
 */
@Slf4j
class SysUserMapperTest extends BaseMapperTest {

    @Resource
    private SysUserMapper userMapper;

    @Test
    @DisplayName("插入一条测试数据并读取")
    void get() {
        // 准备测试数据
        SysUser testUser = new SysUser();
        testUser.setPhone("13800138000");
        testUser.setEmail("test@example.com");
        testUser.setPassword("password123");
        testUser.setBalance(new BigDecimal("100.00"));
        testUser.setUsername("testUser");
        testUser.setStatus(1);
        testUser.setSex(1);
        testUser.setInvalid(0L);
        testUser.setCreateTime(LocalDateTime.now().plusDays(-2));
        int inserted = userMapper.insert(testUser);
        assertThat(inserted).isEqualTo(1);
        assertThat(testUser.getId()).isNotNull();

        // 测试手机号查询
        BaseUserVo userVoByPhone = userMapper.get("13800138000");
        Assertions.assertNotNull(userVoByPhone);
        Assertions.assertEquals("13800138000", userVoByPhone.getPhone());
        log.info("手机号查询测试结果:{}", userVoByPhone);

        // 测试邮箱查询
        BaseUserVo userVoByEmail = userMapper.get("test@example.com");
        Assertions.assertNotNull(userVoByEmail);
        Assertions.assertEquals("test@example.com", userVoByEmail.getEmail());
        log.info("邮箱查询测试结果:{}", userVoByEmail);

    }

    @Test
    @DisplayName("扣减余额测试")
    void deduct() {
        String phone = "15300000017";
        BaseUserVo userVo = userMapper.get(phone);
        assert userVo != null;

        Long userId = userVo.getId();
        BigDecimal balance = userVo.getBalance();

        userMapper.deduct(userId, new BigDecimal("10.00"));
        BaseUserVo after = userMapper.get(phone);
        Assertions.assertNotNull(after);
        Assertions.assertEquals(balance.subtract(new BigDecimal("10.00")), after.getBalance());

        log.info("用户余额应该为:{},实际为:{}", balance.subtract(new BigDecimal("10.00")), after.getBalance());
    }
}

5. 测试要点

  1. 每个测试方法应专注于测试一个功能点
  2. 验证返回值和数据库状态变化
  3. 对于复杂查询,验证查询条件和返回结果

二、Service 层单元测试

1. 测试目标

  • 验证业务逻辑正确性
  • 测试事务管理
  • 验证异常处理
  • 测试服务间调用

2. 依赖配置

除了业务逻辑必要的依赖之外,还需添加如下测试相关依赖

springboot2版本

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Mockito 用于模拟对象 -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- 断言库 -->
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
          	<version>1.9.3</version>
            <scope>test</scope>
        </dependency>

springboot3版本

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>1.12.2</version>
            <scope>test</scope>
        </dependency>

3. 测试案例

注意:在springboot3版本中,下面这一段代码中,需要额外添加一段代码

@BeforeEach
void setUp() {
    // 通过反射设置 baseMapper
    ReflectionTestUtils.setField(userService, "baseMapper", userMapper);
}
package com.yz.mall.sys.service;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yz.mall.sys.dto.SysUserQueryDto;
import com.yz.mall.sys.mapper.SysUserMapper;
import com.yz.mall.sys.service.impl.SysUserServiceImpl;
import com.yz.mall.sys.vo.SysUserVo;
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.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

/**
 * @author yunze
 * @since 2025/7/10 23:35
 */
@ExtendWith(MockitoExtension.class) // 启用 Mockito
class SysUserServiceTest {

    @Mock
    private SysUserMapper userMapper; // 模拟依赖的 Mapper

    @InjectMocks
    private SysUserServiceImpl userService; // 注入被测试的 Service

    @Test
    @DisplayName("用户分页查询测试")
    void page() {
        // 1. 准备 Mock 数据
        int current = 1;
        int size = 10;

        Page<SysUserVo> voPage = new Page<>();
        voPage.setTotal(21);

        List<SysUserVo> mockUsers = new ArrayList<>();
        LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 1, 0, 0);
        for (int i = 0; i < 21; i++) {
            SysUserVo mockUser = new SysUserVo();
            mockUser.setId(1000L + i);
            mockUser.setEmail("test" + i + "@example.com");
            mockUser.setBalance(new BigDecimal("10" + i + ".00"));
            mockUser.setUsername("mockUser" + i);
            mockUser.setStatus(1);
            mockUser.setSex(i % 2 == 0 ? 1 : 0);
            mockUser.setCreateTime(fixedTime);
            mockUsers.add(mockUser);
        }
        int fromIndex = (current - 1) * size;
        int toIndex = Math.min(fromIndex + size, 21);
        voPage.setRecords(mockUsers.subList(fromIndex, toIndex));

        // 2. 定义 Mock 行为
        SysUserQueryDto queryDto = new SysUserQueryDto();

        when(userMapper.selectPage(any(), eq(queryDto))).thenReturn(voPage);

        // 3. 调用被测试方法
        Page<SysUserVo> result = userService.page(1L, 10L, queryDto);

        // 4. 验证结果
        assertEquals(21, result.getTotal());
        assertEquals("test2@example.com", result.getRecords().get(2).getEmail());
        verify(userMapper, times(1)).selectPage(any(), eq(queryDto)); // 验证调用次数
    }
}

4. 测试要点

  1. 使用 Mockito 模拟依赖组件
  2. 测试正常流程和异常流程
  3. 验证业务逻辑正确性
  4. 测试服务间的交互
  5. 验证权限控制和参数校验

三、Controller 层单元测试

1. 测试目标

  • 验证 API 接口的正确性
  • 测试请求参数绑定和验证
  • 验证响应格式和状态码
  • 测试权限控制和异常处理

2. 依赖配置

除了正常api接口必要的spring-boot-starter-web等依赖之外,还需要添加如下依赖信息

springboot2版本

				<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>1.9.3</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>5.7.0</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>

springboot3版本

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>1.12.2</version>
            <scope>test</scope>
        </dependency>

3. 测试案例

package com.yz.mall.sys.controller;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yz.mall.base.PageFilter;
import com.yz.mall.base.enums.CodeEnum;
import com.yz.mall.base.exception.OverallExceptionHandle;
import com.yz.mall.json.JacksonUtil;
import com.yz.mall.sys.dto.SysUserQueryDto;
import com.yz.mall.sys.service.SysUserService;
import com.yz.mall.sys.vo.SysUserVo;
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 org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author yunze
 * @since 2025/7/11 22:45
 */
@ExtendWith(MockitoExtension.class)
class SysUserControllerTest {

    private MockMvc mockMvc;

    @Mock
    private SysUserService userService;

    @InjectMocks
    private SysUserController userController; // 被测试的Controller

    @BeforeEach
    public void setup() {
        // 初始化MockMvc,只配置当前Controller
        mockMvc = MockMvcBuilders.standaloneSetup(userController)
                .setControllerAdvice(new OverallExceptionHandle()) // 可选:添加异常处理器
                .build();
    }

    @Test
    @DisplayName("用户分页查询接口")
    void page() throws Exception {
        // 1. 准备Mock数据
        int current = 1;
        int size = 10;

        Page<SysUserVo> voPage = new Page<>();
        voPage.setTotal(21);

        List<SysUserVo> mockUsers = new ArrayList<>();
        LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 1, 0, 0);
        for (int i = 0; i < 21; i++) {
            SysUserVo mockUser = new SysUserVo();
            mockUser.setId(1000L + i);
            mockUser.setEmail("test" + i + "@example.com");
            mockUser.setBalance(new BigDecimal("10" + i + ".00"));
            mockUser.setUsername("mockUser" + i);
            mockUser.setStatus(1);
            mockUser.setSex(i % 2 == 0 ? 1 : 0);
            mockUser.setCreateTime(fixedTime);
            mockUsers.add(mockUser);
        }
        int fromIndex = (current - 1) * size;
        int toIndex = Math.min(fromIndex + size, 21);
        voPage.setRecords(mockUsers.subList(fromIndex, toIndex));

        // 2. 定义Mock行为
        when(userService.page(1L, 10L, new SysUserQueryDto())).thenReturn(voPage);

        PageFilter<SysUserQueryDto> filter = new PageFilter<>();
        filter.setCurrent(1L);
        filter.setSize(10L);
        SysUserQueryDto queryDto = new SysUserQueryDto();
        filter.setFilter(queryDto);

        String params = JacksonUtil.getObjectMapper().writeValueAsString(filter);

        // 3. 发起请求并验证
        mockMvc.perform(post("/sys/user/page")
                        .contentType(MediaType.APPLICATION_JSON)  // 在perform()内部
                        .content(params))
                .andExpect(jsonPath("$.code").value(CodeEnum.SUCCESS.get()))
                .andExpect(jsonPath("$.data.total").value(21))
                .andExpect(status().isOk());
    }


    @Test
    @DisplayName("用户分页查询接口-未授权")
    public void page_Unauthorized() throws Exception {
        PageFilter<SysUserQueryDto> filter = new PageFilter<>();
        filter.setCurrent(1L);
        filter.setSize(10L);
        SysUserQueryDto queryDto = new SysUserQueryDto();
        filter.setFilter(queryDto);

        String params = JacksonUtil.getObjectMapper().writeValueAsString(filter);

        // 未授权访问
        mockMvc.perform(post("/sys/user/page")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(params))
                .andExpect(status().isUnauthorized());
    }
}

4. 测试要点

  1. 使用 @WebMvcTest 专注于 Web 层测试
  2. 使用 MockMvc 模拟 HTTP 请求
  3. 测试各种 HTTP 方法和状态码
  4. 验证请求参数绑定和校验
  5. 验证异常处理和错误响应格式

四、单元测试最佳实践

1. 测试命名规范

  • 测试类名:被测类名 + Test,如 UserServiceTest
  • 测试方法名:被测方法名 + 测试场景,如 RegisterUser_UsernameExists

2. 测试结构

遵循 Given-When-Then 模式:

@Test
public void methodName_Scenario() {
    // Given - 准备测试数据和模拟行为
    User user = new User();
    user.setUsername("test");
    when(userRepository.save(any(User.class))).thenReturn(user);
    
    // When - 调用被测方法
    User result = userService.createUser(user);
    
    // Then - 验证结果和交互
    assertThat(result.getUsername()).isEqualTo("test");
    verify(userRepository).save(any(User.class));
}

3. 断言选择

优先使用 AssertJ 提供的丰富断言:

import static org.assertj.core.api.Assertions.*;

// 对象断言
assertThat(user).isNotNull();
assertThat(user.getName()).isEqualTo("test");

// 集合断言
assertThat(userList).hasSize(3).extracting("name").contains("Alice", "Bob");

// 异常断言
assertThatThrownBy(() -> service.method(null))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessage("参数不能为空");

4. 测试覆盖率

image-20250801091219365

image-20250801091720561

五、常见问题解决方案

1. 如何测试事务回滚?

@Test
@Transactional
public void testTransactionalMethod() {
    // 测试方法会在事务中执行,默认回滚
    service.transactionalOperation();
    
    // 验证数据库状态
    assertThat(repository.count()).isEqualTo(0);
}

// 需要提交事务的情况
@Test
@Transactional
@Commit
public void testWithCommit() {
    // 测试数据会被提交到数据库
}

2. 如何模拟 Feign 客户端?

@SpringBootTest
@AutoConfigureMockMvc
public class OrderControllerTest {
    
    @MockBean
    private UserServiceClient userServiceClient;
    
    @Test
    public void testGetOrderWithUser() throws Exception {
        // 模拟 Feign 客户端响应
        when(userServiceClient.getUser(anyLong()))
            .thenReturn(new UserVO(1L, "testUser"));
        
        mockMvc.perform(get("/orders/1"))
            .andExpect(jsonPath("$.userName").value("testUser"));
    }
}

3. 如何测试多线程业务逻辑?

@Test
public void testConcurrentOperation() throws InterruptedException {
    int threadCount = 10;
    CountDownLatch latch = new CountDownLatch(threadCount);
    
    for (int i = 0; i < threadCount; i++) {
        new Thread(() -> {
            try {
                service.concurrentOperation();
            } finally {
                latch.countDown();
            }
        }).start();
    }
    
    latch.await(5, TimeUnit.SECONDS);
    assertThat(service.getCounter()).isEqualTo(threadCount);
}

六、总结

本文详细介绍了 Spring Boot + Spring Cloud + MyBatis Plus 项目中各层的单元测试方法:

  1. DAO 层 :直接测试数据库操作,验证 SQL 和 MyBatis Plus 功能
  2. Service 层 :使用 Mockito 模拟依赖,专注于业务逻辑测试
  3. Controller 层 :使用 MockMvc 测试 HTTP 接口,验证请求响应流程

通过遵循这些测试实践,可以显著提高代码质量,减少生产环境中的错误。记住:

  • 保持测试独立性和可重复性
  • 追求合理的测试覆盖率(建议70%以上)
  • 将单元测试作为开发流程的必要环节
  • 定期维护测试代码,保持与生产代码同步