以下是专为Java 后端开发者(Spring Boot 项目)量身定制的《单元测试深度实战指南》,聚焦于纯单元测试(Unit Test)场景,剥离 Spring 上下文依赖,聚焦于类、方法、逻辑的独立验证,涵盖你必须掌握的知识点、工具、方法、最佳实践与企业级编码规范,附带清晰注释的实战代码示例,助力你和团队写出高覆盖率、高可读、高维护性的单元测试。
🎯 单元测试深度实战指南
—— Java 后端开发者的纯单元测试能力图谱(无 Spring 上下文)
✅ 本指南目标:
你写的每个@Service、@Component、工具类,都应能被独立、快速、稳定地测试,不依赖数据库、不启动容器、不调用外部服务。
一、单元测试的核心定义(企业级标准)
| 概念 | 说明 |
|---|---|
| 什么是单元测试? | 测试单个类的一个方法,在完全隔离的环境中,验证其输入与输出逻辑是否符合预期。 |
| 核心原则 | FAST:快速执行(毫秒级) ISOLATED:不依赖外部系统(DB、HTTP、MQ) REPEATABLE:每次运行结果一致 AUTOMATIC:可自动化执行 |
| 与集成测试区别 | 单元测试 = “我这个方法对不对?” 集成测试 = “我这个服务和别人连起来对不对?” |
✅ 企业规范:
- 所有业务逻辑类(Service、Util、Converter、Validator)必须有单元测试
- 单元测试覆盖率 ≥ 80%(行覆盖率)为团队基线
- 单元测试不允许使用
@SpringBootTest、@Autowired(除非是“集成单元测试”,但不推荐)
二、必须掌握的 4 大核心工具链
| 工具 | 作用 | 企业推荐等级 |
|---|---|---|
| JUnit 5 | 测试框架,提供 @Test、assertThrows、assertAll 等核心注解 | ⭐⭐⭐⭐⭐ 必须掌握 |
| Mockito | 模拟依赖对象(Mock),隔离外部调用 | ⭐⭐⭐⭐⭐ 必须掌握 |
| AssertJ | 强大、流畅的断言库,替代 assertEquals | ⭐⭐⭐⭐⭐ 必须掌握 |
| Java 8+ Stream / Optional | 代码逻辑中大量使用,测试必须覆盖边界 | ⭐⭐⭐⭐ 必须掌握 |
🔴 禁止使用:
JUnit 4(@Test+@RunWith)Hamcrest(语法晦涩,团队学习成本高)PowerMock(破坏封装,易导致测试脆弱,仅在遗留系统中不得已使用)
三、单元测试的 5 大核心技能(附实战注释)
✅ 技能 1:使用 Mockito 模拟依赖对象(Mock)
目标:测试
OrderService时,不调用真实的PaymentService或InventoryService,而是用 Mock 对象替代。
// 被测类:OrderService(业务逻辑核心)
@Service
public class OrderService {
private final PaymentService paymentService; // ⚠️ 依赖外部服务
private final InventoryService inventoryService; // ⚠️ 依赖外部服务
public OrderService(PaymentService paymentService, InventoryService inventoryService) {
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
public Order createOrder(OrderRequest request) {
// 1. 校验库存
if (!inventoryService.hasEnoughStock(request.getItemId(), request.getQuantity())) {
throw new BusinessException("库存不足");
}
// 2. 扣减库存
inventoryService.decreaseStock(request.getItemId(), request.getQuantity());
// 3. 支付
PaymentResult paymentResult = paymentService.charge(request.getAmount(), request.getPaymentMethod());
// 4. 创建订单(业务逻辑)
Order order = new Order();
order.setAmount(request.getAmount());
order.setStatus(paymentResult.isSuccess() ? OrderStatus.PAID : OrderStatus.FAILED);
order.setPaymentId(paymentResult.getTransactionId());
return order;
}
}
// ✅ 单元测试:OrderServiceTest(纯单元测试,无 Spring 上下文)
class OrderServiceTest {
// ✅ 1. 手动创建被测对象(非 @Autowired)
private OrderService orderService;
// ✅ 2. 创建 Mock 依赖(不启动任何容器)
// 依赖的Mock对象 (命名规范:mock + 依赖类名)
private PaymentService mockPaymentService;
private InventoryService mockInventoryService;
/**
* 测试初始化方法 - @BeforeEach
* 最佳实践:每个测试前重新初始化,保证测试隔离
*/
@BeforeEach
void setUp() {
// 初始化 Mock 对象
mockPaymentService = mock(PaymentService.class); // Mockito 创建模拟对象
mockInventoryService = mock(InventoryService.class);
// 注入到被测对象(构造函数注入)
// 手动构造被测试对象 - 不通过 Spring 容器注入
orderService = new OrderService(mockPaymentService, mockInventoryService);
}
@Test
void createOrder_ShouldReturnPaidOrder_WhenStockAndPaymentSucceed() {
// Given:准备测试数据
OrderRequest request = new OrderRequest(101L, 2, 199.99, "ALIPAY");
// 模拟依赖行为(关键!)
when(mockInventoryService.hasEnoughStock(101L, 2)).thenReturn(true); // 库存充足
when(mockInventoryService.decreaseStock(101L, 2)).thenReturn(true); // 扣减成功
when(mockPaymentService.charge(199.99, "ALIPAY")).thenReturn(
new PaymentResult(true, "txn_12345") // 支付成功
);
// When:执行被测方法
Order order = orderService.createOrder(request);
// Then:断言结果(使用 AssertJ,语义清晰)
assertThat(order).isNotNull();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
assertThat(order.getPaymentId()).isEqualTo("txn_12345");
// ✅ 验证 Mock 方法是否按预期被调用(防止“假测试”)
verify(mockInventoryService).hasEnoughStock(101L, 2);
verify(mockInventoryService).decreaseStock(101L, 2);
verify(mockPaymentService).charge(199.99, "ALIPAY");
verifyNoMoreInteractions(mockInventoryService, mockPaymentService); // 确保没有多余调用
}
@Test
void createOrder_ShouldThrowException_WhenStockNotEnough() {
// Given
OrderRequest request = new OrderRequest(102L, 10, 500.0, "WECHAT");
// 模拟:库存不足
when(mockInventoryService.hasEnoughStock(102L, 10)).thenReturn(false);
// When & Then:验证异常抛出(使用 assertThrows)
BusinessException exception = assertThrows(
BusinessException.class,
() -> orderService.createOrder(request)
);
assertThat(exception.getMessage()).isEqualTo("库存不足");
// 验证:扣减库存和支付方法**不应被调用**
verify(mockInventoryService).hasEnoughStock(102L, 10);
verifyNoInteractions(mockPaymentService); // 支付服务完全没被调用
}
}
class MockitoAdvancedTest {
@Test
void whenFindUserById_thenReturnUser() {
// Given - 测试数据准备
Long userId = 1L;
User expectedUser = User.builder()
.id(userId)
.name("张三")
.email("zhangsan@example.com")
.build();
// When - 模拟依赖行为 (企业规范:使用BDD风格)
Mockito.when(mockUserRepository.findById(userId))
.thenReturn(Optional.of(expectedUser));
// Then - 执行并验证
User actualUser = userService.getUserById(userId);
// AssertJ流畅断言
assertThat(actualUser)
.isNotNull()
.hasFieldOrPropertyWithValue("id", userId)
.hasFieldOrPropertyWithValue("name", "张三");
// 验证Mock交互 (企业规范:验证必要的交互)
Mockito.verify(mockUserRepository).findById(userId);
Mockito.verify(mockEmailService, Mockito.never()).sendWelcomeEmail(any());
}
@Test
void whenFindNonExistentUser_thenThrowException() {
// Given
Long nonExistentUserId = 999L;
// When - 模拟异常情况
Mockito.when(mockUserRepository.findById(nonExistentUserId))
.thenReturn(Optional.empty());
// Then - 验证异常抛出
assertThatThrownBy(() -> userService.getUserById(nonExistentUserId))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining("用户不存在");
}
}
✅ 企业最佳实践:
- 所有外部依赖(DB、HTTP、Redis、MQ、第三方 API)都必须
mock- 每个
when(...).thenReturn(...)必须有对应的verify(...)- 使用
verifyNoMoreInteractions()避免“过度模拟”或“漏测”
✅ 技能 2:使用 AssertJ 实现优雅、可读的断言
❌ 旧写法(难读、难维护):
assertEquals("PAID", order.getStatus().name());
assertTrue(order.getId() > 0);
assertNull(order.getCancelReason());
✅ 新写法(AssertJ —— 推荐):
assertThat(order)
.isNotNull()
.extracting(Order::getStatus)
.isEqualTo(OrderStatus.PAID)
.extracting(Order::getId)
.isGreaterThan(0)
.extracting(Order::getCancelReason)
.isNull()
.extracting(Order::getAmount)
.isEqualTo(BigDecimal.valueOf(199.99))
.satisfies(o -> assertThat(o.getCreatedAt()).isAfter(Instant.now().minusSeconds(10)));
✅ 优势:
- 链式调用,一行代码验证多个属性
- 自动提示(IDE 智能感知)
- 错误信息清晰:“期望是 PAID,实际是 FAILED”
- 支持集合、Optional、日期、BigDecimal 等复杂类型
📌 常用断言示例:
// Optional
assertThat(optionalUser).isPresent();
assertThat(optionalUser).contains(user);
// List
assertThat(orderList).hasSize(3)
.allMatch(o -> o.getStatus() == OrderStatus.CREATED);
// String
assertThat(name).isNotBlank().startsWith("张").endsWith("先生");
// 异常
assertThrows(IllegalArgumentException.class, () -> service.process(null));
// 多断言(避免一个失败就中断)
assertAll(
() -> assertThat(user.getName()).isEqualTo("李四"),
() -> assertThat(user.getEmail()).contains("@company.com"),
() -> assertThat(user.getAge()).isBetween(18, 65)
);
✅ 企业规范:
所有单元测试必须使用 AssertJ,禁止使用assertEquals、assertTrue等 JUnit 原生断言
✅ 技能 3:测试边界条件与异常路径
单元测试的价值不在于“正常流程”,而在于覆盖所有异常和边界情况。
@Test
void calculateDiscount_ShouldReturnZero_WhenPriceIsZero() {
// Given
DiscountCalculator calculator = new DiscountCalculator(); // 无依赖,纯逻辑类
// When
BigDecimal discount = calculator.calculateDiscount(BigDecimal.ZERO, 0.1);
// Then
assertThat(discount).isEqualByComparingTo(BigDecimal.ZERO); // BigDecimal 比较推荐用此方法
}
@Test
void calculateDiscount_ShouldThrowException_WhenDiscountRateIsNegative() {
DiscountCalculator calculator = new DiscountCalculator();
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> calculator.calculateDiscount(BigDecimal.valueOf(100), -0.1)
);
assertThat(ex.getMessage()).isEqualTo("折扣率不能为负数");
}
@Test
void calculateDiscount_ShouldReturnCorrectAmount_WhenRateIsOne() {
BigDecimal price = BigDecimal.valueOf(500);
BigDecimal discount = new DiscountCalculator().calculateDiscount(price, 1.0);
assertThat(discount).isEqualByComparingTo(price); // 100% 折扣 = 全额
}
✅ 测试覆盖清单(必须覆盖):
| 类型 | 示例 |
|---|---|
| 空值(null) | 输入参数为 null |
| 边界值 | 0、-1、Integer.MAX_VALUE、空字符串 |
| 异常路径 | 无效参数、权限不足、状态冲突 |
| 集合空/满 | List.size() == 0 / 1000 |
| Optional.empty() | 方法返回 Optional.empty() |
| 并发安全 | 仅在必要时测试(如工具类有共享状态) |
✅ 建议:使用 参数化测试 测试多组边界值(见下节)
✅ 技能 4:使用 @ParameterizedTest 实现多组数据测试
目标:避免重复写 10 个
@Test方法,用一个方法测试 10 组输入输出。
@ParameterizedTest
@CsvSource({
"100, 0.1, 10.0", // 100元打9折 = 10元折扣
"500, 0.2, 100.0",
"0, 0.5, 0.0", // 边界:价格为0
"100, 0.0, 0.0", // 无折扣
"100, 1.0, 100.0" // 全额折扣
})
void calculateDiscount_ShouldReturnExpectedValue(BigDecimal price, double rate, BigDecimal expected) {
// Given
DiscountCalculator calculator = new DiscountCalculator();
// When
BigDecimal result = calculator.calculateDiscount(price, rate);
// Then
assertThat(result).isEqualByComparingTo(expected);
}
// ✅ 也可以用 @MethodSource 提供复杂对象
@ParameterizedTest
@MethodSource("provideInvalidPaymentMethods")
void processPayment_ShouldThrowException_WhenPaymentMethodInvalid(String method) {
PaymentProcessor processor = new PaymentProcessor();
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> processor.process("user123", method, BigDecimal.valueOf(100))
);
assertThat(ex.getMessage()).isEqualTo("不支持的支付方式:" + method);
}
static Stream<Arguments> provideInvalidPaymentMethods() {
return Stream.of(
Arguments.of(""),
Arguments.of(null),
Arguments.of("CASH"),
Arguments.of("UNKNOWN")
);
}
✅ 企业建议:
- 所有“输入-输出”映射逻辑(如计算、转换、校验)都用
@ParameterizedTest- 用
@CsvFileSource从外部 CSV 文件加载测试数据(适合大数据集)
✅ 技能 5:测试工具类、静态方法、不可变对象
很多业务逻辑封装在工具类中,它们没有依赖,是单元测试的“黄金对象”。
// 工具类:身份证校验
public class IdCardValidator {
public static boolean isValid(String idCard) {
if (idCard == null || idCard.length() != 18) return false;
// 简化校验逻辑(实际应校验校验位)
return idCard.matches("\\d{17}[\\dX]");
}
public static String extractBirthDate(String idCard) {
if (!isValid(idCard)) throw new IllegalArgumentException("无效身份证号");
return idCard.substring(6, 14); // 19900101
}
}
class IdCardValidatorTest {
@Test
void isValid_ShouldReturnFalse_WhenNull() {
assertThat(IdCardValidator.isValid(null)).isFalse();
}
@Test
void isValid_ShouldReturnFalse_WhenLengthNot18() {
assertThat(IdCardValidator.isValid("123")).isFalse();
}
@Test
void isValid_ShouldReturnTrue_WhenValidFormat() {
assertThat(IdCardValidator.isValid("11010119900307231X")).isTrue();
}
@Test
void extractBirthDate_ShouldThrowException_WhenInvalid() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> IdCardValidator.extractBirthDate("invalid")
);
assertThat(ex.getMessage()).isEqualTo("无效身份证号");
}
@Test
void extractBirthDate_ShouldReturnCorrectDate() {
String birth = IdCardValidator.extractBirthDate("11010119900307231X");
assertThat(birth).isEqualTo("19900307");
}
}
✅ 关键点:
- 工具类方法是
static,无需实例化,直接ClassName.method()- 这类测试最稳定、最快、最容易维护
- 强烈建议:把业务逻辑从 Service 拆解到工具类,便于测试
四、进阶:如何测试“难测”的代码?(企业实战技巧)
| 场景 | 解法 |
|---|---|
依赖 Spring 的 @Value 注入 | 改为构造函数注入,或使用 @TestConstructor + 构造器传参 |
依赖 LocalDateTime.now() | 使用 Clock 注入,测试时传入固定时间:Clock.fixed(Instant.parse("2025-10-16T10:00:00Z"), ZoneId.systemDefault()) |
依赖 Thread.sleep() | 改为 Duration + ScheduledExecutorService,测试时用 TestClock 或 Awaitility |
| 依赖文件读写 | 使用 ByteArrayInputStream / StringReader 模拟文件内容,避免真实文件系统 |
| 依赖数据库(JPA Repository) | 单元测试中禁止访问 DB! → 使用 @MockBean 或改造成 Repository 接口 + Service 层逻辑分离,只测 Service |
✅ 终极建议:
“所有外部依赖,都应通过接口抽象,通过构造函数注入。”
这是实现可测试性的唯一正道。
五、单元测试的“黄金法则”(团队强制执行)
| 法则 | 说明 |
|---|---|
| 🔹 每个测试只测一件事 | 一个 @Test 方法只验证一个业务场景 |
| 🔹 测试命名清晰 | methodName_WhenCondition_ThenExpectedResult → createOrder_WhenStockNotEnough_ThenThrowException |
| 🔹 测试不依赖顺序 | 每个测试独立,@BeforeEach 初始化,不共享状态 |
| 🔹 测试速度快 | 单元测试应在 100ms 内完成,否则就是“集成测试” |
| 🔹 拒绝“空测试” | @Test void testSomething() {} 是反模式 |
| 🔹 测试即文档 | 优秀的测试,能让新人看懂业务逻辑 |
六、推荐工具与 IDE 插件(提升效率)
| 工具 | 作用 |
|---|---|
| IntelliJ IDEA | 内置 JUnit / Mockito 支持,右键 → “Generate Test” 自动生成骨架 |
| JaCoCo | 代码覆盖率插件,生成 HTML 报告,CI 中门禁检查 |
| TestContainers | ❌ 不属于单元测试,仅用于集成测试(DB、Redis 容器化) |
| AssertJ Generator | 自动生成 AssertJ 断言代码(IDE 插件) |
| Mockito Inline | 支持 Mock final 类、静态方法(仅在必要时使用) |
✅ CI 门禁配置(Maven):
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
<goal>report</goal>
</goals>
</execution>
</executions>
<configuration>
<rules>
<rule>
<element>CLASS</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>
✅ 总结:单元测试能力图谱(你的团队必须掌握)
| 能力层级 | 必须掌握内容 |
|---|---|
| 基础 | JUnit 5 注解、@Test、assertThrows、assertAll |
| 核心 | Mockito:mock()、when().thenReturn()、verify()、verifyNoMoreInteractions() |
| 进阶 | AssertJ:链式断言、集合、Optional、BigDecimal、自定义断言 |
| 实战 | 参数化测试、边界值覆盖、工具类测试、时间/文件/外部依赖模拟 |
| 工程 | 测试命名规范、测试即文档、覆盖率门禁、CI 自动化 |
🚀 一句话总结:
“你写的单元测试越干净,你的代码就越健壮;你越能模拟依赖,你的系统就越可维护。”
📎 附:单元测试模板(可直接复制)
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) // 自动注入 @Mock 和 @InjectMocks
class UserServiceTest {
@Mock
private UserRepository userRepository; // 自动 Mock
@InjectMocks
private UserService userService; // 自动注入 Mock
@Test
void findUserById_ShouldReturnUser_WhenUserExists() {
// Given
Long userId = 1L;
User mockUser = new User(userId, "张三", "zhangsan@company.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));
// When
Optional<User> result = userService.findUserById(userId);
// Then
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("张三");
// Verify
verify(userRepository).findById(userId);
}
@Test
void findUserById_ShouldReturnEmpty_WhenUserNotFound() {
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
assertThat(userService.findUserById(userId)).isEmpty();
verify(userRepository).findById(userId);
}
}
✅ 建议行动:
- 立即在团队中推行 “所有新代码必须有单元测试”
- 用 AssertJ + Mockito + JUnit 5 替代旧有测试方式
- 在 Code Review 中,拒绝任何没有测试的 Service/Util 类
- 每周进行一次 “测试质量复盘”,提升团队测试意识

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



