以下是针对 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 风格增强可读性,流畅断言提升诊断效率,三者协同构建可维护、可诊断、可演进的测试资产。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐