Spring Boot 单元测试是针对 Spring Boot 应用中单个组件(如服务类、工具类、控制器等)的最小可测试单元,进行独立、快速、自动化验证的测试方法。它不依赖外部系统(如数据库、网络服务),通过模拟(Mock)依赖项来隔离被测代码,确保测试的稳定性和高效性。
单元测试的作用
- 验证逻辑正确性:确保每个方法在各种输入条件下都能返回预期结果。
- 快速反馈:在代码修改后立即发现回归缺陷,显著缩短调试周期。
- 提高代码质量:迫使开发者思考边界条件和异常路径,促进代码设计更清晰、可维护。
- 文档作用:良好的测试用例本身就是“可执行的文档”,说明了方法如何使用。
- 支持重构安全:在重构代码时,有完整测试覆盖可确保功能不变,增强信心。
- 提升团队协作效率:减少因代码变更导致的意外问题,降低集成阶段的冲突。
为什么需要写单元测试?
- 降低维护成本:后期修复 Bug 的成本远高于预防。
- 保障核心业务稳定:尤其在金融、保险等高可靠性领域,逻辑错误可能导致严重后果。
- 支持持续集成(CI):自动化构建流程必须通过单元测试才能通过。
- 提升开发自信:有测试覆盖的代码,开发者更敢改、敢优化。
单元测试的标准写法(JUnit 5 + Mockito + Spring Boot)
标准写法遵循 AAA 模式:
- Arrange(准备):初始化被测对象、设置输入数据、模拟依赖。
- Act(执行):调用被测方法。
- Assert(断言):验证输出结果或行为是否符合预期。
关键原则:
- 每个测试方法只测试一个场景
- 测试名称清晰表达意图(如
shouldReturnUserWhenValidIdProvided)- 避免测试逻辑复杂,不涉及 I/O、网络、数据库
- 使用
@Test注解,不使用@SpringBootTest(那是集成测试)
单元测试的目标
- 验证被测单元的功能行为是否符合设计规范
- 确保代码在各种边界条件和异常输入下表现正确
- 建立可重复、自动化、快速执行的验证机制
单元测试的目标是功能正确性 + 可维护性 + 快速反馈,而非系统整体运行。
推荐对私有方法进行单元测试吗?
❌ 不推荐直接测试私有方法。
原因:
- 私有方法是实现细节,不是对外接口。测试应关注行为而非实现。
- 直接测试私有方法会破坏封装性,使测试与实现强耦合,重构时易失效。
- 私有方法通常被公有方法调用,通过测试公有方法间接覆盖私有逻辑即可。
✅ 正确做法:
确保公有方法的测试用例覆盖了私有方法的所有执行路径(如分支、异常等)。
单元测试中的疑难杂点
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 依赖外部服务(如数据库、HTTP) | 导致测试慢、不可靠 | 使用 @MockBean 或 Mockito.mock() 模拟依赖 |
| 测试随机数、时间、UUID | 结果不稳定 | 使用 Mockito.when(...).thenReturn(...) 固定返回值 |
| 测试异步方法 | 无法立即获取结果 | 使用 awaitility 或 CountDownLatch 等待完成 |
| 测试 Spring Bean 注入 | 需要上下文启动太慢 | 使用 @ExtendWith(MockitoExtension.class) + 手动 new 对象,避免 @SpringBootTest |
| 测试异常抛出 | 需验证异常类型和信息 | 使用 assertThrows() 捕获并断言 |
| 测试私有方法或静态方法 | 不易直接调用 | 通过公有方法间接测试;静态方法可重构为工具类+接口抽象 |
单元测试常用的工具
| 工具 | 用途 |
|---|---|
| JUnit 5 | 主流 Java 单元测试框架,支持 @Test, @BeforeEach, @Disabled 等 |
| Mockito | 模拟对象(Mock),用于隔离依赖,如 when(service.get()).thenReturn(...) |
| AssertJ | 更流畅、可读性更强的断言库(如 assertThat(result).isEqualTo(...)) |
| Hamcrest | 提供更灵活的匹配器(如 hasProperty("name", is("张三"))) |
| Awaitility | 用于异步操作的等待断言 |
| Spring Boot Test | 提供 @WebMvcTest, @DataJpaTest 等注解,用于切片测试(非全上下文) |
| JaCoCo | 代码覆盖率统计工具,用于衡量测试覆盖程度 |
单元测试中的重要概念
| 概念 | 说明 |
|---|---|
| 测试隔离(Isolation) | 每个测试独立,不依赖其他测试状态 |
| SUT(System Under Test) | 被测试的系统/对象 |
| Mock / Stub | Mock:模拟对象行为并验证调用;Stub:返回预设值,不验证调用 |
| 测试覆盖率 | 衡量代码被测试执行的比例(行、分支、方法) |
| 测试金字塔 | 单元测试 > 集成测试 > 端到端测试,应以单元测试为主力 |
| 测试双倍(Test Double) | 包括 Mock、Stub、Fake、Dummy、Spy 等模拟对象类型 |
| TDD(测试驱动开发) | 先写测试,再写实现,驱动设计 |
综合性单元测试示例(带详细中文注释)
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
// 使用 MockitoExtension 自动注入 @Mock 和 @InjectMocks,无需手动 initMocks
@ExtendWith(MockitoExtension.class)
class InsuranceClaimServiceTest {
// 模拟依赖:保险理赔申请仓库(Repository)
@Mock
private InsuranceClaimRepository claimRepository;
// 模拟依赖:客户信息服务(外部服务)
@Mock
private CustomerService customerService;
// 被测试的系统(SUT):理赔服务类
@InjectMocks
private InsuranceClaimService claimService;
// 测试数据准备(每个测试前执行)
@BeforeEach
void setUp() {
// 初始化 Mockito 注解(如果未使用 @ExtendWith,则需手动调用 MockitoAnnotations.openMocks(this))
MockitoAnnotations.openMocks(this);
}
/**
* 测试场景:当用户提交合法的理赔申请时,系统应成功创建并返回理赔ID
* 场景:客户存在、申请金额合理、无重复申请
*/
@Test
void shouldCreateClaimSuccessfullyWhenValidRequest() {
// Arrange:准备测试数据
Long customerId = 1001L;
String claimDescription = "车祸理赔";
Double claimAmount = 5000.0;
String claimId = "CLAIM-2025-001";
// 模拟客户信息服务:当调用 getCustomer(1001L) 时,返回一个有效客户对象
when(customerService.getCustomer(customerId))
.thenReturn(new Customer(customerId, "张三", "active"));
// 模拟仓库:当调用 findByCustomerIdAndStatus(customerId, "PENDING") 时,返回空(无重复申请)
when(claimRepository.findByCustomerIdAndStatus(customerId, "PENDING"))
.thenReturn(Optional.empty());
// 模拟仓库:当调用 save() 时,保存后返回一个包含生成ID的理赔对象
InsuranceClaim savedClaim = new InsuranceClaim(claimId, customerId, claimDescription, claimAmount, "PENDING", LocalDate.now());
when(claimRepository.save(any(InsuranceClaim.class))).thenReturn(savedClaim);
// Act:执行被测试的方法
String resultClaimId = claimService.createClaim(customerId, claimDescription, claimAmount);
// Assert:验证结果是否符合预期
assertThat(resultClaimId).isEqualTo(claimId); // 验证返回的理赔ID正确
assertThat(savedClaim.getStatus()).isEqualTo("PENDING"); // 验证状态正确
assertThat(savedClaim.getAmount()).isEqualTo(claimAmount); // 验证金额正确
// 验证依赖方法被正确调用(行为验证)
verify(customerService, times(1)).getCustomer(customerId); // 客户服务被调用1次
verify(claimRepository, times(1)).findByCustomerIdAndStatus(customerId, "PENDING"); // 检查重复申请被查询
verify(claimRepository, times(1)).save(any(InsuranceClaim.class)); // 保存操作被调用1次
}
/**
* 测试场景:当客户不存在时,应抛出业务异常
*/
@Test
void shouldThrowCustomerNotFoundExceptionWhenCustomerDoesNotExist() {
// Arrange
Long invalidCustomerId = 9999L;
// 模拟:客户不存在,返回 null
when(customerService.getCustomer(invalidCustomerId)).thenReturn(null);
// Act & Assert:验证异常被正确抛出
CustomerNotFoundException exception = assertThrows(
CustomerNotFoundException.class,
() -> claimService.createClaim(invalidCustomerId, "事故", 1000.0)
);
// 验证异常信息
assertThat(exception.getMessage()).isEqualTo("客户ID 9999 不存在");
}
/**
* 测试场景:当存在未处理的相同客户申请时,拒绝重复提交
*/
@Test
void shouldRejectDuplicateClaimWhenPendingClaimExists() {
// Arrange
Long customerId = 1002L;
// 模拟:存在一个待处理的理赔申请
InsuranceClaim existingClaim = new InsuranceClaim("CLAIM-2024-001", customerId, "旧申请", 2000.0, "PENDING", LocalDate.now());
when(claimRepository.findByCustomerIdAndStatus(customerId, "PENDING"))
.thenReturn(Optional.of(existingClaim));
// Act & Assert:验证抛出业务异常
DuplicateClaimException exception = assertThrows(
DuplicateClaimException.class,
() -> claimService.createClaim(customerId, "新申请", 3000.0)
);
assertThat(exception.getMessage()).isEqualTo("客户已有待处理的理赔申请,不能重复提交");
}
/**
* 测试场景:当理赔金额为负数时,应抛出参数异常
*/
@Test
void shouldThrowIllegalArgumentExceptionWhenClaimAmountIsNegative() {
// Arrange
Long customerId = 1003L;
double negativeAmount = -500.0;
// Act & Assert
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> claimService.createClaim(customerId, "测试", negativeAmount)
);
assertThat(exception.getMessage()).isEqualTo("理赔金额不能为负数");
}
}
补充说明:测试类结构说明
@ExtendWith(MockitoExtension.class):自动注入@Mock和@InjectMocks,替代手动MockitoAnnotations.openMocks(this)。@Mock:创建模拟对象,不执行真实逻辑。@InjectMocks:自动将模拟对象注入到被测类中(依赖注入)。assertThrows():JUnit 5 断言异常抛出。verify():验证模拟对象的方法是否被调用,次数是否正确。assertThat().isEqualTo():来自 AssertJ,语法更自然,支持链式调用。
总结建议
- 优先测试公有接口,通过路径覆盖间接验证私有方法。
- 每个测试方法只测一个场景,命名清晰(如
should...when...)。 - 不要测试 Spring 容器行为(如
@Autowired是否生效),那是集成测试的范畴。 - 保持测试快速:单元测试应在毫秒级完成,避免 I/O。
- 覆盖率目标:核心业务逻辑 ≥ 80%,边界条件和异常路径必须覆盖。
✅ 在银行保险系统中,单元测试是保障理赔计算、客户验证、风控逻辑准确性的基石。
投入编写高质量单元测试,等于为你的代码买了“保险”。
10万+

被折叠的 条评论
为什么被折叠?



