单元测试框架选型:JUnit 5 vs TestNG vs Spock

说到单元测试框架,我这些年也算是把主流的都折腾了一遍。从最早的JUnit 4,到后来项目里强推TestNG,再到最近几年沉迷Spock的简洁。说实话,没有银弹,每个框架都有自己的脾气。

今天我就把这三个框架掰开揉碎了讲,顺便聊聊断言库和Mock工具怎么选。嗯,这些都是我在真实项目里踩过的坑,希望能帮你少走弯路。

JUnit 5:生态之王,稳如老狗

JUnit 5是目前Java生态里最主流的测试框架。为什么?因为它背后有整个Spring生态在撑腰。你想想看,Spring Boot 2.2+默认集成的就是JUnit 5,这基本就是官方钦定的节奏。

我个人习惯用JUnit 5做大部分微服务的单元测试。它的架构设计很清晰——JUnit Platform负责启动,JUnit Jupiter提供编程模型,JUnit Vintage兼容老版本。这套设计让我在升级老项目时几乎零成本。

核心特性:

  • @Test、@ParameterizedTest、@RepeatedTest 三种测试模式
  • @DisplayName 支持中文描述,对非技术人员友好
  • @Nested 实现测试类分层,结构清晰
  • 动态测试 @TestFactory,运行时生成测试用例
@DisplayName("订单服务单元测试")
class OrderServiceTest {

    @Nested
    @DisplayName("创建订单场景")
    class CreateOrder {

        @Test
        @DisplayName("正常创建订单应该成功")
        void shouldCreateOrderSuccessfully() {
            // 测试逻辑
        }

        @Test
        @DisplayName("库存不足应该抛出异常")
        void shouldThrowWhenStockInsufficient() {
            // 测试逻辑
        }
    }
}

我在项目中遇到过一个问题:JUnit 5的参数化测试虽然强大,但数据源如果写死在@CsvSource里,维护起来很痛苦。后来我改用@MethodSource,把测试数据抽到单独的方法里,清爽多了。

TestNG:老牌劲旅,灵活至上

TestNG当年可是为了干掉JUnit 4而生的。它的设计理念更贴近企业级测试——支持测试组、依赖测试、并行执行。如果你在做集成测试或者端到端测试,TestNG的灵活性会让你爱不释手。

我记得有个项目需要按模块分组执行测试,JUnit 5当时还没@Tag,TestNG的@Test(groups = "smoke")直接搞定。不过现在JUnit 5也有@Tag了,差距在缩小。

什么时候选TestNG?

  • 需要复杂的测试依赖关系(比如A测试失败就不跑B测试)
  • 需要按组执行测试(冒烟测试、回归测试分开跑)
  • 需要并行执行测试用例(TestNG原生支持多线程)
public class PaymentServiceTest {

    @Test(groups = {"smoke", "regression"})
    public void testPaymentSuccess() {
        // 冒烟测试和回归测试都会执行
    }

    @Test(dependsOnMethods = {"testPaymentSuccess"})
    public void testRefund() {
        // 只有支付成功后才执行退款测试
    }
}

避坑指南:我曾经在TestNG里滥用dependsOnMethods,结果测试用例之间形成了复杂的依赖链。一旦中间某个测试失败,后面一串都不执行,排查问题非常痛苦。后来我改成每个测试独立,用@BeforeClass做前置准备,反而更稳定。

Spock:Groovy加持,测试如诗

Spock是我个人最喜欢的测试框架,没有之一。它基于Groovy,语法极其简洁。你想想看,一个测试用例用JUnit写可能要30行,Spock 10行搞定。而且它的given-when-then结构,读起来就像在讲故事。

不过Spock有个硬伤——团队需要会Groovy。我有个项目强推Spock,结果新来的Java工程师一脸懵逼,最后又切回JUnit 5了。所以选型时一定要考虑团队的技术栈。

Spock的杀手锏:

  • 数据驱动测试(where块)—— 测试数据与逻辑分离
  • 交互测试(Mockito集成)—— 验证方法调用次数、参数
  • 异常测试(thrown())—— 比JUnit的assertThrows更直观
class InventoryServiceSpec extends Specification {

    def "扣减库存-正常场景"() {
        given: "商品库存充足"
        def product = new Product(stock: 10)

        when: "扣减5件"
        inventoryService.deduct(product, 5)

        then: "库存变为5"
        product.stock == 5
    }

    def "扣减库存-参数化测试"() {
        expect: "计算正确"
        calculator.add(a, b) == result

        where:
        a | b || result
        1 | 2 || 3
        3 | 4 || 7
        0 | 0 || 0
    }
}

说实话,Spock的where块是我最爱的功能。以前用JUnit写参数化测试,数据源要么是CSV要么是Excel,维护起来很麻烦。Spock直接在测试里定义数据表,一目了然。

断言库:AssertJ vs Hamcrest

断言库的选择其实很简单——我建议无脑上AssertJ。为什么?因为它的链式调用太舒服了。

特性 AssertJ Hamcrest
语法风格 链式调用,流畅自然 静态方法,略显啰嗦
错误信息 详细且可读性强 一般,需要额外配置
集合断言 extracting、filteredOn 等强大API hasItem、hasSize 等基础功能
自定义断言 支持,但需要写模板代码 通过Matcher实现
// AssertJ 链式调用
assertThat(orders)
    .hasSize(3)
    .extracting(Order::getStatus)
    .containsExactly("PAID", "SHIPPED", "DELIVERED");

// Hamcrest 静态方法
assertThat(orders, hasSize(3));
assertThat(orders.get(0).getStatus(), is("PAID"));

我个人习惯在微服务项目里统一用AssertJ。它的错误信息非常友好,比如assertThat(actual).isEqualTo(expected)失败时,会明确告诉你「期望是X,实际是Y」,而不是像JUnit原生断言那样只抛个AssertionError。

Mockito与PowerMock实战

Mockito是目前Java生态里最主流的Mock框架。它的设计哲学很简单——创建假对象,定义行为,验证交互。对于99%的微服务测试场景,Mockito完全够用。

Mockito核心API速记:

  • mock() —— 创建Mock对象
  • when().thenReturn() —— 定义返回值
  • verify() —— 验证方法调用
  • @InjectMocks —— 自动注入Mock对象
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private InventoryClient inventoryClient;

    @Mock
    private PaymentClient paymentClient;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCreateOrder() {
        // 定义Mock行为
        when(inventoryClient.checkStock("PROD-001", 2))
            .thenReturn(true);
        when(paymentClient.charge(any(PaymentRequest.class)))
            .thenReturn(new PaymentResponse("SUCCESS"));

        // 执行测试
        Order order = orderService.createOrder("PROD-001", 2);

        // 验证交互
        verify(inventoryClient).checkStock("PROD-001", 2);
        verify(paymentClient).charge(any(PaymentRequest.class));
    }
}

避坑指南:我曾经在Mockito里滥用any()匹配器,结果某个参数类型变了,测试依然通过,上线直接报错。后来我改成用eq()精确匹配,或者用ArgumentCaptor捕获参数做断言,安全多了。

至于PowerMock,说实话,我建议你尽量别用。它通过修改字节码来Mock静态方法、私有方法、构造函数,虽然强大,但副作用也很明显——测试启动慢,兼容性问题多,而且容易掩盖设计缺陷。

PowerMock使用原则:

  • 能不用就不用,优先考虑重构代码
  • 如果必须用,只Mock静态方法或私有方法
  • 不要和Mockito混用,容易出兼容性问题
  • 升级JDK版本时,PowerMock往往是第一个出问题的
// PowerMock示例(慎用)
@RunWith(PowerMockRunner.class)
@PrepareForTest(StaticUtils.class)
public class StaticUtilsTest {

    @Test
    public void testStaticMethod() {
        PowerMockito.mockStatic(StaticUtils.class);
        when(StaticUtils.getConfig("timeout"))
            .thenReturn("5000");

        String result = StaticUtils.getConfig("timeout");
        assertEquals("5000", result);
    }
}

我记得有个遗留项目,代码里到处都是静态方法调用,没法改。当时硬着头皮上了PowerMock,结果每次跑测试都要等30秒加载字节码。后来我们花了两个迭代把静态方法改成依赖注入,测试速度从30秒降到了3秒。所以,PowerMock只是临时止痛药,重构才是根本。

选型建议总结

说了这么多,到底怎么选?我给出一个简单粗暴的建议:

  • 新项目、Spring生态 → JUnit 5 + AssertJ + Mockito
  • 老项目、需要分组测试 → TestNG + AssertJ + Mockito
  • 团队熟悉Groovy、追求简洁 → Spock + Mockito
  • 有遗留代码、无法重构 → 临时用PowerMock,但尽快重构

最后说一句,框架只是工具,测试思维才是核心。你想想看,如果代码本身设计得一团糟,再好的测试框架也救不了。所以,先把代码写好,再谈测试框架选型。