JUnit 5与TestNG深度对比:Java测试框架选型实战指南

1. 项目概述:为什么我们需要一个测试框架选型指南?

在Java开发的世界里,写测试代码和写业务代码几乎同等重要。但每次启动一个新项目,或者接手一个遗留系统时,一个老生常谈的问题总会浮出水面:我们该用JUnit 5还是TestNG?这绝不是一个可以拍脑袋随便决定的问题。我见过不少团队,因为早期选型不慎,导致后期测试代码臃肿不堪、维护成本飙升,甚至因为框架的某些限制,不得不对核心业务逻辑进行妥协性的修改。

我自己就踩过这样的坑。几年前,我们团队接手了一个基于Spring Boot的微服务项目,当时为了“省事”和“社区流行”,直接沿用了JUnit 4。但随着业务复杂度的提升,我们需要更灵活的测试分组、依赖测试和并行执行策略时,JUnit 4的局限性就暴露无遗。重构测试框架的代价,远比一开始就做出正确选择要大得多。所以,今天我想从一个一线开发者的角度,抛开那些官方的特性列表,结合我实际项目中的使用、迁移和踩坑经验,来深度拆解JUnit 5和TestNG。目标很简单:给你一份能直接用到项目里的、有血有肉的选型指南,让你不再纠结。

简单来说,JUnit 5是Java单元测试领域事实上的“官方标准”,模块化设计,注解驱动,与IDE和构建工具集成度极高。而TestNG则自诩为“下一代”测试框架,设计之初就瞄准了更广泛的测试类型(单元、集成、端到端),在灵活性和功能丰富度上更胜一筹。选择哪一个,取决于你的项目类型、团队习惯以及对测试“灵活性”和“标准化”的权衡。

2. 核心架构与设计哲学对比

要理解两个框架的差异,必须先看它们的“骨架”和“灵魂”。这决定了它们能做什么,以及你怎么用它们。

2.1 JUnit 5:模块化与标准化的复兴

JUnit 5完全重写了,它不再是单个JAR包,而是由三个明确分离的子模块组成:

  1. JUnit Jupiter:这是你编写测试和扩展时主要交互的模块,提供了新的编程模型和扩展API。
  2. JUnit Vintage:提供了一个在JUnit 5平台上运行JUnit 3/4测试的引擎,这是向后兼容的关键,让迁移变得平滑。
  3. JUnit Platform:这是基石,它定义了在JVM上启动测试框架的通用API。正是这个平台,让IDE(如IntelliJ IDEA、Eclipse)和构建工具(如Maven、Gradle)能够以统一的方式发现和执行测试,无论底层用的是JUnit Jupiter、TestNG还是其他引擎。

设计哲学:JUnit 5的核心思想是“关注点分离”和“强类型”。它鼓励清晰的、独立的单元测试。它的注解(如@Test@BeforeEach)意图明确,一个测试方法就应该只测试一件事。这种设计使得测试代码结构清晰,易于理解和维护,特别适合遵循“测试驱动开发”(TDD)和追求高测试覆盖率的团队。

实操心得:JUnit 5的模块化意味着你的pom.xmlbuild.gradle依赖会稍微复杂一点。但这是值得的,因为它让你可以只引入需要的部分。例如,如果你不需要运行旧的JUnit 4测试,完全可以不引入junit-vintage-engine

2.2 TestNG:为灵活性而生的全能选手

TestNG的架构更像一个“一体化”的解决方案。虽然它也有类似的概念,但它的设计从一开始就考虑了更复杂的测试场景。

设计哲学:TestNG的哲学是“配置优于约定”和“极致的灵活性”。它通过一个强大的XML配置文件(testng.xml)来驱动整个测试过程。你可以在这个文件里定义测试套件、指定包含哪些测试类/方法、定义测试方法的执行顺序、设置并行策略、传递参数,甚至配置监听器。这种将测试逻辑与配置分离的方式,赋予了测试极大的灵活性和可管理性,特别适合需要复杂测试流程(如集成测试流水线)的项目。

核心差异点:与JUnit 5的“平台-引擎”分离不同,TestNG自己就包含了发现、执行、报告的全套能力。它更像一个自包含的测试执行系统。这种设计让它在处理测试依赖、分组和参数化方面,提供了比JUnit更原生、更强大的支持。

踩过的坑:TestNG的灵活性是一把双刃剑。过度依赖testng.xml可能导致配置变得极其复杂且难以维护。我曾经见过一个项目的testng.xml文件超过500行,任何修改都战战兢兢。相比之下,JUnit 5更倾向于用注解和编程式API在代码中表达测试结构,虽然灵活性稍逊,但可读性和可维护性通常更好。

3. 核心特性深度解析与实操要点

光看架构太抽象,我们直接上代码,看看在日常开发中,两个框架的关键特性是如何实现的,以及你该如何选择。

3.1 生命周期钩子:测试的“脚手架”

生命周期钩子定义了测试方法执行前后,以及测试类初始化/销毁时需要运行的代码。这是设置测试环境(如准备数据库连接、初始化Mock对象)和清理资源的关键。

JUnit 5 的实现: JUnit 5的注解非常直观,与JUnit 4一脉相承但更清晰:

  • @BeforeAll/@AfterAll:在整个测试类的所有测试方法执行之前/之后运行一次。方法必须是static
  • @BeforeEach/@AfterEach:在每个测试方法执行之前/之后运行。
import org.junit.jupiter.api.*; class JUnit5LifecycleTest { @BeforeAll static void initAll() { System.out.println("初始化整个测试类的资源,例如数据库连接池"); } @BeforeEach void init() { System.out.println("为每个测试方法准备干净的环境"); } @Test void testOne() { System.out.println("执行测试一"); } @AfterEach void tearDown() { System.out.println("清理每个测试方法产生的数据"); } @AfterAll static void tearDownAll() { System.out.println("释放整个测试类的资源"); } }

TestNG 的实现: TestNG的注解更丰富,粒度更细:

  • @BeforeSuite/@AfterSuite:在整个测试套件开始前/结束后运行。
  • @BeforeTest/@AfterTest:在<test>标签(testng.xml中定义)级别的测试开始前/结束后运行。
  • @BeforeClass/@AfterClass:在测试类开始前/结束后运行。
  • @BeforeMethod/@AfterMethod:在每个测试方法前/后运行(相当于JUnit的@BeforeEach/@AfterEach)。
  • @BeforeGroups/@AfterGroups:在特定分组测试开始前/结束后运行。
import org.testng.annotations.*; public class TestNGLifecycleTest { @BeforeSuite public void beforeSuite() { System.out.println("BeforeSuite - 套件级别初始化"); } @BeforeClass public void beforeClass() { System.out.println("BeforeClass - 类级别初始化"); } @BeforeMethod public void beforeMethod() { System.out.println("BeforeMethod - 方法级别初始化"); } @Test public void testCase1() { System.out.println("Test Case 1"); } @AfterMethod public void afterMethod() { System.out.println("AfterMethod - 方法级别清理"); } // ... 其他注解类似 }

选型指南

  • 选择JUnit 5:如果你的测试场景相对标准,主要是单元测试和简单的集成测试,JUnit 5清晰、简单的生命周期(4个主要注解)完全够用,且更易于理解和记忆。
  • 选择TestNG:如果你需要进行多层次的、复杂的集成测试或端到端测试。例如,你需要为整个测试套件准备一个昂贵的资源(如启动一个Docker容器集群),用@BeforeSuite就非常合适。@BeforeGroups/@AfterGroups能让你精确控制特定功能模块测试的初始化和清理。

3.2 断言:验证测试结果的“标尺”

断言是测试的核心,用于验证实际结果是否符合预期。

JUnit 5 的断言: JUnit 5提供了Assertions类,包含一系列静态方法。从JUnit 5.4开始,更推荐使用基于Lambda的assertAllassertThrows,它们能提供更好的错误信息。

import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class JUnit5AssertionsTest { @Test void standardAssertions() { assertEquals(2, 1 + 1); assertTrue(5 > 3, "这个失败消息会在断言失败时显示"); } @Test void groupedAssertions() { // assertAll 确保所有断言都会执行,即使中间有失败 assertAll("用户信息校验", () -> assertEquals("John", user.getFirstName()), () -> assertEquals("Doe", user.getLastName()) ); } @Test void exceptionTesting() { // 验证是否抛出了特定异常 IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> { throw new IllegalArgumentException("参数错误"); } ); assertEquals("参数错误", exception.getMessage()); } }

TestNG 的断言: TestNG的断言通过Assert类提供,功能与JUnit类似,但语法略有不同。它同样支持软断言。

import org.testng.Assert; import org.testng.annotations.Test; import org.testng.asserts.SoftAssert; public class TestNGAssertionsTest { @Test void hardAssertions() { Assert.assertEquals(1 + 1, 2); Assert.assertTrue(5 > 3, "失败信息"); } @Test void softAssertions() { SoftAssert softAssert = new SoftAssert(); softAssert.assertEquals(1, 2, "第一个断言会失败,但测试会继续"); softAssert.assertTrue(false, "第二个断言也失败"); // 必须调用 assertAll() 来收集所有失败信息 softAssert.assertAll(); // 这里会抛出包含所有失败信息的异常 } }

选型指南

  • 断言功能本身:两者在基础断言上旗鼓相当。JUnit 5的assertAll和Lambda表达式让编写分组断言和异常测试非常优雅。
  • 软断言:这是TestNG的一个显著优势。在UI自动化或集成测试中,你经常需要验证页面上多个元素。使用硬断言,第一个失败就会终止测试,你无法知道后面还有多少问题。TestNG的SoftAssert允许你收集所有断言失败,最后统一报告,这对于调试和生成全面的测试报告至关重要。JUnit 5社区有第三方库(如SoftAssertionsfrom AssertJ)可以实现类似功能,但非原生支持。
  • 如果你大量依赖软断言,TestNG的原生支持会更方便、更标准。

3.3 参数化测试:数据驱动测试的利器

参数化测试允许你用不同的输入数据多次运行同一个测试逻辑,是提高测试覆盖率的有效手段。

JUnit 5 的参数化测试: JUnit 5的参数化测试功能非常强大且灵活,通过@ParameterizedTest注解和一系列“源”注解(如@ValueSource@CsvSource@MethodSource)来实现。它需要额外引入junit-jupiter-params依赖。

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.*; import static org.junit.jupiter.api.Assertions.*; class JUnit5ParameterizedTest { // 1. 使用简单值 @ParameterizedTest @ValueSource(ints = {1, 3, 5, -3, 15}) void testIsOdd(int number) { assertTrue(number % 2 != 0); } // 2. 使用CSV格式数据(非常常用) @ParameterizedTest @CsvSource({ "apple, 5", "banana, 6", "'', 0" }) void testStringLength(String input, int expectedLength) { assertEquals(expectedLength, input.length()); } // 3. 使用外部方法提供复杂对象 @ParameterizedTest @MethodSource("provideUsersForTest") void testUserAge(User user, boolean expectedIsAdult) { assertEquals(expectedIsAdult, user.getAge() >= 18); } private static Stream<Arguments> provideUsersForTest() { return Stream.of( Arguments.of(new User("Alice", 25), true), Arguments.of(new User("Bob", 16), false) ); } }

TestNG 的参数化测试: TestNG通过@DataProvider注解来实现,它更强调“数据提供者”的概念。@DataProvider方法返回一个Object[][]Iterator<Object[]>,每个一维数组对应测试方法的一组参数。

import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import static org.testng.Assert.*; public class TestNGParameterizedTest { // 测试方法声明它使用哪个 DataProvider @Test(dataProvider = "adultUserProvider") public void testUserIsAdult(User user, boolean expected) { assertEquals(user.getAge() >= 18, expected); } // DataProvider 方法,负责提供测试数据 @DataProvider(name = "adultUserProvider") public Object[][] provideAdultUserData() { return new Object[][] { { new User("Alice", 25), true }, { new User("Bob", 16), false }, { new User("Charlie", 18), true } }; } // DataProvider 也可以定义在别的类中,并通过 dataProviderClass 指定 @Test(dataProvider = "externalData", dataProviderClass = ExternalDataProvider.class) public void testWithExternalData(String input) { // ... 测试逻辑 } }

选型指南

  • 数据来源的灵活性:JUnit 5的“源”注解(@CsvSource@CsvFileSource@MethodSource等)提供了多种开箱即用的数据注入方式,对于从CSV文件、内联数据或工厂方法获取数据非常方便。@MethodSource配合Stream API,能生成非常动态和复杂的数据集。
  • 数据提供者的复用与组织TestNG的@DataProvider在数据复用和组织上更胜一筹。你可以将复杂的@DataProvider方法放在一个专门的工具类中,供多个测试类共用。这对于大型项目,需要统一管理测试数据(如从数据库、Excel读取)的场景非常有利。JUnit 5的@MethodSource虽然也能引用外部类的静态方法,但TestNG的这种模式更为经典和清晰。
  • 如果你有大量、结构复杂且需要跨类共享的测试数据,TestNG的@DataProvider模式可能更易于管理。

3.4 测试依赖与分组:构建灵活的测试流程

这是TestNG的传统强项,也是它与JUnit在设计理念上差异最大的地方之一。

TestNG 的依赖测试: 你可以明确声明一个测试方法依赖于另一个或多个测试方法的成功执行。

@Test public void serverStartedOk() { // 模拟启动服务器 assertTrue(startServer()); } @Test(dependsOnMethods = { "serverStartedOk" }) public void method1() { // 这个方法只会在 serverStartedOk 成功后才执行 System.out.println("执行 method1, 因为服务器已启动"); } @Test(dependsOnGroups = { "init" }) public void method2() { // 这个方法依赖于“init”分组中的所有测试成功 }

TestNG 的分组测试: 你可以给测试方法打上“标签”(分组),然后选择性地只运行某个或某几个分组的测试。

@Test(groups = { "fast", "unit" }) public void aFastUnitTest() { // ... } @Test(groups = { "slow", "integration" }) public void aSlowIntegrationTest() { // ... }

然后,在testng.xml或通过Maven/Gradle插件,你可以指定只运行fast分组或排除slow分组的测试。这对于区分单元测试、集成测试、冒烟测试等不同测试阶段极其有用。

JUnit 5 的对应能力: JUnit 5本身不直接支持测试方法间的依赖关系。它的哲学是:每个测试都应该是独立的、可隔离执行的。依赖关系会破坏测试的原子性和可重复性,使得测试结果难以理解和调试。

  • 对于分组:JUnit 5使用@Tag注解来实现类似功能。你可以给测试类或方法打标签。
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag("fast") class FastTests { @Test @Tag("unit") void aFastUnitTest() { // ... } }

然后,在构建工具(如Gradle)或IDE中,你可以通过标签来过滤要运行的测试。

test { useJUnitPlatform { includeTags 'fast' excludeTags 'slow' } }

选型指南

  • 坚决选择JUnit 5:如果你的团队严格遵循单元测试的最佳实践(即测试之间完全独立,没有顺序依赖),那么JUnit 5的“无依赖”设计是更优选择。它迫使你写出更干净、更可靠的测试。测试依赖是“代码异味”,它通常意味着你的测试没有做好充分的准备或清理工作。
  • 谨慎考虑TestNG:如果你面对的是复杂的集成测试、端到端测试(如Selenium UI测试)。在这些场景下,测试步骤之间存在天然的依赖关系(例如,必须先登录才能执行后续操作)。TestNG的依赖和分组功能,为管理这类有状态的、流程化的测试提供了强大的原生工具。你可以用dependsOnMethods来构建测试流程,用groups来区分冒烟测试、回归测试套件,并通过testng.xml灵活组合。这是TestNG在特定领域无可替代的优势。

3.5 并行测试执行:加速测试套件

在现代多核CPU环境下,并行运行测试能极大缩短反馈时间。

TestNG 的并行执行: TestNG在并行测试方面非常成熟和强大。你可以在testng.xml中非常精细地控制并行粒度:

<suite name="MySuite" parallel="tests" thread-count="5"> <test name="Test1" parallel="methods" thread-count="2"> <classes>...</classes> </test> <test name="Test2"> <classes>...</classes> </test> </suite>
  • parallel="tests":不同的<test>标签会并行执行。
  • parallel="methods":同一个<test>内的测试方法会并行执行。
  • parallel="classes":测试类会并行执行。 你还可以通过@Test(threadPoolSize = 3, invocationCount = 10)让一个测试方法在多线程下被多次调用。

JUnit 5 的并行执行: JUnit 5本身不直接处理并行,而是将控制权交给了构建工具。这是其平台化设计的一部分。

  • 在Gradle中:你可以在build.gradle中配置。
test { useJUnitPlatform() maxParallelForks = 4 // 并行fork的JVM进程数 }
  • 在Maven中:使用maven-surefire-plugin配置。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <parallel>methods</parallel> <threadCount>4</threadCount> </configuration> </plugin>

JUnit 5还提供了@Execution注解来在类或方法级别覆盖并行模式(如@Execution(CONCURRENT)),但需要相应的配置支持。

选型指南

  • 配置便利性:TestNG通过一个XML文件集中管理所有并行策略,对于习惯中心化配置的团队来说更直观。
  • 与构建工具集成:JUnit 5将并行控制交给Gradle/Maven,这更符合现代构建工具“约定优于配置”的理念,与CI/CD流水线的集成可能更顺畅。Gradle的maxParallelForks是基于进程的隔离,比线程隔离更彻底,能避免一些共享状态导致的问题。
  • 如果你已经深度使用testng.xml来管理测试套件,那么用TestNG配置并行会非常自然。如果你的项目主要使用Gradle,并且希望配置更简单、与构建流程结合更紧密,JUnit 5的方式也不错。两者在最终的执行效率上差异不大,更多是配置风格的偏好。

4. 与生态系统的集成:现实项目的考量

框架不是孤岛,它需要和你的开发工具链、第三方库完美协作。

4.1 与IDE和构建工具的集成

IDE支持:两者都得到了IntelliJ IDEA、Eclipse等主流IDE的顶级支持。你可以轻松地运行单个测试、类、包或整个套件。JUnit 5由于是“官方标准”,有时在新特性支持上会略快一步(比如对Java新版本特性的支持)。

构建工具

  • Maven/Gradle:两者都有成熟的插件。maven-surefire-plugingradle test任务都原生支持JUnit和TestNG。配置上大同小异。
  • 关键区别:由于JUnit 5的模块化,其依赖声明稍多。TestNG通常只需一个依赖项。

4.2 与Mock框架的集成(如Mockito)

这是单元测试的命脉。好消息是,Mockito与JUnit 5和TestNG都集成得非常好

  • JUnit 5:需要通过mockito-junit-jupiter依赖来启用Mockito的@Mock@InjectMocks等注解的支持。它提供了一个MockitoExtension
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ServiceTestWithJUnit5 { @Mock private Repository repository; @InjectMocks private Service service; @Test void testSomething() { when(repository.findById(1L)).thenReturn(new Entity()); // ... 测试 service 的方法 } }
  • TestNG:通常使用mockito-testng依赖,或者直接使用Mockito的API,因为TestNG本身没有像JUnit那样的扩展模型。更常见的做法是在@BeforeMethod中初始化Mock。
import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import static org.mockito.Mockito.*; public class ServiceTestWithTestNG { @Mock private Repository repository; private Service service; @BeforeMethod public void setUp() { MockitoAnnotations.openMocks(this); // 初始化 @Mock 注解 service = new Service(repository); } @Test public void testSomething() { when(repository.findById(1L)).thenReturn(new Entity()); // ... 测试 } }

集成体验:两者都能很好地工作。JUnit 5的扩展模型(@ExtendWith)使得集成更声明式、更优雅。TestNG的方式则更传统和直接。

4.3 测试报告

清晰的测试报告对于CI/CD和问题诊断至关重要。

  • JUnit 5:生成标准的JUnit XML报告(TEST-*.xml),可以被Jenkins、TeamCity等所有主流CI服务器解析。此外,通过junit-platform-reporting模块可以生成更丰富的HTML报告。
  • TestNG:内置了非常强大的报告功能。默认会生成一个index.html报告,包含详细的测试结果、分组信息、时间线、依赖关系图等,可读性极佳。它也生成XML报告供CI工具使用。

报告对比TestNG的原生HTML报告通常比JUnit 5默认生成的更美观、信息更丰富,特别是对于分组测试和依赖测试,它能直观地展示测试套件的结构。JUnit 5社区有第三方插件(如allure-junit5)可以生成非常漂亮的报告,但这需要额外集成。

5. 迁移成本与团队因素

技术选型不能只看技术,还要看人和历史。

5.1 从JUnit 4迁移

  • 迁移到JUnit 5:这是最自然的路径。JUnit 5提供了junit-vintage-engine来兼容旧的JUnit 4测试,允许你逐步迁移。你可以先让新旧测试共存,然后逐个将JUnit 4的测试类重写为JUnit 5。注解的变化(@Before->@BeforeEach)是机械性的,但一些规则(如@BeforeClass方法必须是static)需要特别注意。总体而言,迁移路径清晰,风险可控。
  • 迁移到TestNG:这相当于框架的彻底更换。你需要重写所有的测试类(替换注解、改变断言导入、可能还要重写生命周期方法)。如果项目大量使用了JUnit 4的Rule(如TemporaryFolderExpectedException),迁移工作量会更大。除非有非常强烈的理由(如极度依赖TestNG的分组/依赖功能),否则从JUnit 4迁移到TestNG的性价比通常不高。

5.2 团队技能与偏好

  • 如果团队大多数成员对JUnit更熟悉,选择JUnit 5的学习成本更低,社区资源(Stack Overflow上的问题、博客教程)也更多。
  • 如果团队已经熟悉TestNG,或者项目是继承自一个使用TestNG的代码库,那么继续使用TestNG是更务实的选择。
  • 考虑招聘和市场情况:JUnit作为Java测试的“普通话”,几乎每个Java开发者都懂。TestNG虽然也很流行,但普及度略逊。新成员加入团队时,JUnit 5的上手速度可能更快。

6. 最终选型决策矩阵

经过以上层层剖析,我们可以总结出一个简单的决策矩阵,帮助你在具体场景下做出选择:

考量维度优先选择 JUnit 5优先选择 TestNG
项目类型全新的Spring Boot项目、纯单元测试为主的项目、微服务组件测试。大型的、测试类型混合(单元+集成+端到端)的项目、遗留的TestNG项目。
核心需求追求测试的独立性、简洁性、与Java/Jakarta EE生态(如Spring, Micronaut)的深度集成。需要强大的测试分组、依赖管理、灵活的并行执行配置、以及原生软断言支持。
测试复杂度测试场景相对标准,测试之间无状态依赖。测试流程复杂,存在明确的步骤依赖(如:登录 -> 添加商品 -> 下单)。
团队与生态团队熟悉JUnit,希望采用社区事实标准,便于招聘和知识共享。项目大量使用Spring(其测试框架对JUnit 5支持极佳)。团队已有TestNG经验,或项目历史包袱是TestNG。需要非常丰富和美观的内置HTML报告。
配置风格偏好使用代码(注解)和构建工具(Gradle/Maven)来配置测试,而非XML。偏好使用中心化的testng.xml文件来管理和配置所有的测试套件、分组和并行策略。

我个人的经验法则

  • 对于95%的以单元测试和简单集成测试为主的现代Java项目(特别是Spring Boot项目),我会毫不犹豫地推荐JUnit 5。它的设计更现代,与Java生态融合得更好,能促使团队写出更干净的测试。Spring Bootspring-boot-starter-test默认就包含了JUnit 5,开箱即用,集成体验完美。
  • 只有当你明确需要TestNG那些“杀手级”特性,并且这些特性对你的测试策略至关重要时,才选择TestNG。例如,你正在构建一个复杂的Selenium自动化测试框架,测试用例之间有很强的业务流程依赖,并且你需要通过testng.xml为不同的环境(开发、测试、生产)动态配置不同的测试套件。在这些场景下,TestNG提供的工具链更趁手。

最后,无论选择哪个,一致性比选择本身更重要。在一个项目或一个团队内,尽量统一测试框架,这能降低维护成本,避免不必要的混乱。希望这篇从实战出发的对比,能帮你做出那个最适合你当前项目的、不后悔的决定。