【测试体系】单元测试:JUnit 5(@ParameterizedTest、@RepeatedTest)、Mockito(Behavior-Driven)、AssertJ流畅断言
·
以下是针对 Java 后端单元测试体系的工业化实践指南,涵盖 JUnit 5 现代特性、Mockito BDD 行为验证与 AssertJ 流畅断言的协同作战模式。
一、JUnit 5 架构与核心特性
1.1 模块化架构(JUnit Platform + Jupiter + Vintage)
<!-- Maven 依赖(Spring Boot 2.2+ 已自动引入) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
1.2 参数化测试(@ParameterizedTest)
场景:避免重复测试逻辑,用多组数据驱动同一测试逻辑。
数据源矩阵:
| 注解 | 用途 | 示例 |
|---|---|---|
@ValueSource |
基础类型数组 | strings = {"admin", "user"} |
@CsvSource |
CSV 格式多参数 | {"1, true", "0, false"} |
@MethodSource |
工厂方法返回流 | 动态生成复杂对象 |
@EnumSource |
枚举子集 | mode = EXCLUDE 排除特定值 |
@ArgumentsSource |
自定义参数类 | 实现 ArgumentsProvider |
生产级示例(验证手机号格式):
@ParameterizedTest
@DisplayName("验证手机号格式合法性")
@CsvSource({
"13800138000, true, 标准手机号",
"1380013800, false, 位数不足",
"23800138000, false, 非1开头",
"1380013800a, false, 含非数字",
"'', false, 空字符串",
"null, false, null值" // 注意:CSV中null是字符串,需特殊处理
})
void shouldValidatePhoneNumber(String phone, boolean expectedValid, String description) {
// given
PhoneValidator validator = new PhoneValidator();
// when
boolean actual = validator.isValid(phone);
// then
assertThat(actual)
.as("测试场景: %s, 输入: %s", description, phone)
.isEqualTo(expectedValid);
}
// 复杂对象参数(MethodSource)
static Stream<Arguments> provideOrderDiscountCases() {
return Stream.of(
Arguments.of(new Order(1000, VIP.GOLD), 800, "金卡8折"),
Arguments.of(new Order(1000, VIP.SILVER), 900, "银卡9折"),
Arguments.of(new Order(100, VIP.NONE), 100, "无折扣")
);
}
@ParameterizedTest
@MethodSource("provideOrderDiscountCases")
@DisplayName("计算订单折扣金额")
void shouldCalculateDiscount(Order order, BigDecimal expected, String desc) {
assertThat(calculator.apply(order))
.as(desc)
.isEqualByComparingTo(expected);
}
1.3 重复测试与性能测试(@RepeatedTest + @Timeout)
@RepeatedTest(value = 5, name = "第 {currentRepetition}/{totalRepetitions} 次")
@DisplayName("并发安全性测试(重复5次以暴露竞态条件)")
void shouldBeThreadSafe() throws InterruptedException {
// 执行并发操作,验证最终一致性
ConcurrentTestRunner.run(100, () -> counter.increment());
assertThat(counter.get()).isEqualTo(100);
}
// 性能测试(超时断言)
@Test
@Timeout(value = 100, unit = TimeUnit.MILLISECONDS) // 超过100ms则失败
@DisplayName("复杂查询应在100ms内完成")
void shouldCompleteWithin100ms() {
assertThat(service.complexQuery()).isNotEmpty();
}
二、Mockito BDD(行为驱动开发风格)
2.1 BDD 风格 vs 传统风格
| 阶段 | 传统 Mockito | BDD Mockito | 语义 |
|---|---|---|---|
| 准备 | when(mock.method()).thenReturn(x) |
given(mock.method()).willReturn(x) |
Given(假设前提) |
| 执行 | service.doSomething() |
service.doSomething() |
When(执行操作) |
| 验证 | verify(mock).method() |
then(mock).should().method() |
Then(验证结果) |
生产级示例(订单支付流程):
@ExtendWith(MockitoExtension.class) // JUnit 5 扩展
class OrderServiceTest {
@Mock private PaymentClient paymentClient;
@Mock private InventoryService inventoryService;
@InjectMocks private OrderService orderService;
@Test
@DisplayName("支付成功时扣减库存并发送通知")
void shouldDeductInventoryAndNotifyWhenPaymentSuccess() {
// Given(准备上下文)
Order order = Order.builder().id("ORD-001").amount(1000).build();
PaymentRequest request = new PaymentRequest(order.getId(), order.getAmount());
given(paymentClient.charge(request))
.willReturn(PaymentResult.success("TXN-123"));
given(inventoryService.deduct(order.getId())).willReturn(true);
willDoNothing().given(notificationService).send(any(OrderConfirmation.class));
// When(执行行为)
OrderResult result = orderService.pay(order);
// Then(验证行为与状态)
assertThat(result.getStatus()).isEqualTo(OrderStatus.PAID);
then(paymentClient).should(times(1)).charge(request);
then(inventoryService).should(only()).deduct(order.getId()); // 验证仅调用一次
then(notificationService).should(atMostOnce()).send(any());
// 验证参数捕获(ArgumentCaptor)
ArgumentCaptor<OrderConfirmation> captor = ArgumentCaptor.forClass(OrderConfirmation.class);
then(notificationService).should().send(captor.capture());
assertThat(captor.getValue().getOrderId()).isEqualTo("ORD-001");
}
@Test
@DisplayName("支付失败时抛出异常且不回滚库存")
void shouldThrowExceptionWhenPaymentFailed() {
// Given
given(paymentClient.charge(any()))
.willThrow(new PaymentException("Insufficient balance"));
// When & Then(异常断言)
assertThatThrownBy(() -> orderService.pay(new Order()))
.isInstanceOf(OrderException.class)
.hasMessageContaining("Payment failed")
.hasCauseInstanceOf(PaymentException.class);
then(inventoryService).should(never()).deduct(any()); // 验证未调用
}
}
2.2 高级验证模式
验证调用顺序:
@Test
void shouldExecuteInOrder() {
// when
service.process();
// then
InOrder inOrder = inOrder(auditLog, database, cache);
then(auditLog).should(inOrder).record(any());
then(database).should(inOrder).save(any());
then(cache).should(inOrder).evict(any());
}
异步验证(Timeout):
// 验证方法在100ms内被调用
then(asyncService).should(timeout(100)).process(any());
三、AssertJ 流畅断言体系
3.1 基础断言对比
| 场景 | JUnit 5 原生 | AssertJ | 优势 |
|---|---|---|---|
| 相等 | assertEquals(a, b) |
assertThat(a).isEqualTo(b) |
错误信息可读,支持链式 |
| 空值 | assertNull(obj) |
assertThat(obj).isNull() |
统一风格 |
| 异常 | assertThrows() |
assertThatThrownBy() |
链式验证异常属性 |
| 集合 | assertIterableEquals() |
assertThat(list).containsExactly() |
丰富集合操作 |
3.2 实战断言模式
集合断言(订单列表验证):
@Test
void shouldReturnOrdersWithSpecificProperties() {
List<Order> orders = service.findPendingOrders();
assertThat(orders)
.as("待处理订单列表")
.hasSize(3)
.extracting(Order::getStatus) // 提取字段断言
.containsOnly(OrderStatus.PENDING) // 全为待处理状态
.doesNotContain(OrderStatus.COMPLETED);
// 对象字段组合断言
assertThat(orders)
.filteredOn(o -> o.getAmount() > 1000) // 过滤
.hasSize(2)
.extracting("id", "amount") // 提取多字段为元组
.containsExactlyInAnyOrder(
tuple("ORD-001", 1500),
tuple("ORD-003", 2000)
);
}
异常链式断言:
@Test
void shouldValidateErrorDetails() {
assertThatThrownBy(() -> validator.validate(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("参数不能为空")
.hasNoCause()
.satisfies(ex -> {
// 自定义复杂验证
assertThat(ex.getStackTrace()[0].getClassName())
.contains("OrderValidator");
});
}
日期与时间断言:
assertThat(order.getCreateTime())
.isCloseTo("2024-01-15T10:00:00", within(1, ChronoUnit.MINUTES))
.isBefore(LocalDateTime.now())
.isInSameHourAs(LocalDateTime.of(2024, 1, 15, 10, 0));
文件与路径断言:
assertThat(Paths.get("/tmp/report.pdf"))
.exists()
.isRegularFile()
.hasContent("Expected content"); // 文本内容验证
3.3 自定义断言(Domain-Specific)
// 1. 自定义断言类
public class OrderAssert extends AbstractAssert<OrderAssert, Order> {
public static OrderAssert assertThat(Order actual) {
return new OrderAssert(actual);
}
public OrderAssert(Order actual) {
super(actual, OrderAssert.class);
}
public OrderAssert isPaid() {
isNotNull();
if (actual.getStatus() != OrderStatus.PAID) {
failWithMessage("Expected order to be paid but was %s", actual.getStatus());
}
return this;
}
public OrderAssert hasTotalAmount(BigDecimal expected) {
isNotNull();
if (actual.getTotalAmount().compareTo(expected) != 0) {
failWithMessage("Expected total amount %s but was %s", expected, actual.getTotalAmount());
}
return this;
}
}
// 2. 使用自定义断言
@Test
void shouldCreateValidOrder() {
Order order = service.createOrder(items);
OrderAssert.assertThat(order)
.isPaid()
.hasTotalAmount(new BigDecimal("199.99"))
.satisfies(o -> assertThat(o.getItems()).hasSize(3)); // 组合标准断言
}
四、测试生命周期与扩展机制
4.1 生命周期回调(替代 @Before/@After)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 默认PER_METHOD(每个测试新建实例)
class DatabaseTest {
@BeforeAll
void init() { /* 仅执行一次 */ }
@BeforeEach
void setUp() { /* 每个测试前执行 */ }
@AfterEach
void tearDown() { /* 每个测试后清理 */ }
// 条件执行(生产数据跳过)
@Test
@EnabledIfSystemProperty(named = "env", matches = "test|dev")
void shouldNotRunInProd() { }
@Test
@DisabledOnOs(OS.WINDOWS) // 跳过Windows特定测试
void unixSpecificTest() { }
}
4.2 扩展机制(Extension API)
自定义扩展(自动重置 MDC):
public class MdcExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
MDC.clear();
MDC.put("testName", context.getDisplayName());
}
@Override
public void afterEach(ExtensionContext context) {
MDC.clear();
}
}
// 使用
@ExtendWith({MockitoExtension.class, MdcExtension.class})
class MyTest { }
五、生产级测试规范
5.1 命名规范(BDD 风格)
// 反例
@Test
void test1() { }
// 正例:should[ExpectedBehavior]When[StateUnderTest]
@Test
void shouldThrowInvalidOrderExceptionWhenOrderAmountIsNegative() { }
// 或 Given/When/Then 描述
@DisplayName("当库存不足时,下单应失败并提示用户")
void shouldFailOrderWhenInventoryInsufficient() { }
5.2 测试结构(AAA 模式)
@Test
void shouldCalculateDiscountForVipMember() {
// Arrange(准备)
Order order = new Order(1000, MemberLevel.VIP);
// Act(执行)
BigDecimal result = calculator.calculate(order);
// Assert(验证)
assertThat(result).isEqualByComparingTo("900.00");
}
5.3 性能与隔离
// 禁用并行测试(若测试共享资源)
@Execution(ExecutionMode.SAME_THREAD)
class IntegrationTest { }
// 超时控制(防止死循环测试)
@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS)
void shouldCompleteQuickly() { }
六、检查清单(Checklist)
□ 是否使用 @DisplayName 描述测试意图(而非仅方法名)
□ 参数化测试是否覆盖边界值(null、空值、极限值)
□ Mockito 是否使用 BDD 风格(given/when/then)提高可读性
□ AssertJ 是否替代了 Hamcrest/JUnit 原生断言(利用其类型安全与流畅API)
□ 是否使用 ArgumentCaptor 验证复杂参数(而非 any() 模糊匹配)
□ 测试数据是否通过 @MethodSource 与业务代码隔离(便于维护)
□ 是否使用 @Timeout 防止测试死循环拖垮 CI
□ 自定义断言是否提取了领域特定验证逻辑(如 OrderAssert.isPaid())
□ 是否使用 @ExtendWith 扩展(而非继承 TestCase 基类)
□ 敏感操作(DB/外部API)是否使用 @Mock 隔离(单元测试不依赖基础设施)
核心认知:现代 Java 单元测试的黄金三角是 JUnit 5(执行引擎)+ Mockito(依赖隔离)+ AssertJ(断言表达)。参数化测试消灭重复代码,BDD 风格增强可读性,流畅断言提升诊断效率,三者协同构建可维护、可诊断、可演进的测试资产。
更多推荐



所有评论(0)