Spring Boot + Spring Cloud + MyBatis Plus 单元测试指南
本文将详细介绍如何在 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. 测试要点
- 每个测试方法应专注于测试一个功能点
- 验证返回值和数据库状态变化
- 对于复杂查询,验证查询条件和返回结果
二、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. 测试要点
- 使用 Mockito 模拟依赖组件
- 测试正常流程和异常流程
- 验证业务逻辑正确性
- 测试服务间的交互
- 验证权限控制和参数校验
三、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. 测试要点
- 使用
@WebMvcTest
专注于 Web 层测试 - 使用
MockMvc
模拟 HTTP 请求 - 测试各种 HTTP 方法和状态码
- 验证请求参数绑定和校验
- 验证异常处理和错误响应格式
四、单元测试最佳实践
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. 测试覆盖率
五、常见问题解决方案
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 项目中各层的单元测试方法:
- DAO 层 :直接测试数据库操作,验证 SQL 和 MyBatis Plus 功能
- Service 层 :使用 Mockito 模拟依赖,专注于业务逻辑测试
- Controller 层 :使用 MockMvc 测试 HTTP 接口,验证请求响应流程
通过遵循这些测试实践,可以显著提高代码质量,减少生产环境中的错误。记住:
- 保持测试独立性和可重复性
- 追求合理的测试覆盖率(建议70%以上)
- 将单元测试作为开发流程的必要环节
- 定期维护测试代码,保持与生产代码同步