TestNG 深度实战——数据驱动测试、分组执行、依赖测试、并行策略
TestNG 深度实战——数据驱动测试、分组执行、依赖测试、并行策略
适读人群:用过 JUnit 但不了解 TestNG 的工程师,或者用 TestNG 只会基础 @Test 的开发者 | 阅读时长:约14分钟 | 核心价值:掌握 TestNG 的核心差异化特性,在合适的场景选对工具
我为什么在某些项目里选 TestNG
2019年我在做一个接口自动化测试平台,框架选型时,我选了 TestNG 而不是 JUnit。
理由很简单:当时我需要做三件事——
- 把测试数据放在 Excel 里,让测试工程师(不会写 Java)维护用例
- 部分接口测试有依赖关系(先登录,再下单,再查订单)
- 每天晚上 2000 个接口用例要在 15 分钟内跑完
这三个需求,JUnit 4 当时做起来都比较麻烦。TestNG 却原生支持:数据提供者(Data Provider)、测试依赖(dependsOnMethods)、XML 配置的并行策略。
今天 JUnit 5 已经补上了很多这方面的短板,但 TestNG 在某些场景依然有它的优势,特别是在接口测试和系统测试领域。
这篇我把 TestNG 最有价值的四个特性讲透。
数据驱动测试:@DataProvider
TestNG 的 @DataProvider 比 JUnit 5 的 @ParameterizedTest 更灵活,能支持复杂对象参数、多维度数据组合。
基础用法
public class PriceCalculatorTest {
@DataProvider(name = "priceTestData")
public Object[][] providePriceData() {
return new Object[][] {
{"NORMAL", 100.0, 0.0, 100.0},
{"MEMBER", 100.0, 0.0, 90.0},
{"VIP", 100.0, 0.0, 85.0},
{"SVIP", 100.0, 10.0, 70.0}, // VIP 折扣 + 优惠券
{"VIP", 0.0, 0.0, 0.0}, // 零元订单
{"NORMAL", -50.0, 0.0, 0.0}, // 负数(按 0 处理)
};
}
@Test(dataProvider = "priceTestData",
description = "验证不同会员等级和优惠券组合的价格计算")
public void testCalculatePrice(
String memberType, double baseAmount, double couponDiscount, double expectedFinal) {
double actual = PriceCalculator.calculate(memberType, baseAmount, couponDiscount);
assertEquals(actual, expectedFinal, 0.001,
String.format("会员[%s] 原价[%.1f] 优惠[%.1f] 期望[%.1f] 实际[%.1f]",
memberType, baseAmount, couponDiscount, expectedFinal, actual));
}
}复杂对象的数据提供
@DataProvider(name = "orderScenarios")
public Object[][] provideOrderScenarios() {
return new Object[][] {
{
Order.builder().userId(1L).amount(new BigDecimal("100")).status(OrderStatus.PENDING).build(),
UserInfo.builder().id(1L).level("NORMAL").balance(new BigDecimal("500")).build(),
ProcessExpectation.success(new BigDecimal("100"))
},
{
Order.builder().userId(2L).amount(new BigDecimal("5000")).status(OrderStatus.PENDING).build(),
UserInfo.builder().id(2L).level("VIP").balance(new BigDecimal("3000")).build(),
ProcessExpectation.failure("余额不足")
}
};
}
@Test(dataProvider = "orderScenarios")
public void testProcessOrder(Order order, UserInfo user, ProcessExpectation expectation) {
when(userService.getUser(order.getUserId())).thenReturn(user);
if (expectation.isSuccess()) {
ProcessResult result = orderService.process(order);
assertTrue(result.isSuccess());
assertEquals(expectation.getExpectedAmount(), result.getActualAmount());
} else {
Exception ex = expectThrows(BusinessException.class, () -> orderService.process(order));
assertTrue(ex.getMessage().contains(expectation.getErrorMessage()));
}
}从外部来源加载数据
@DataProvider(name = "csvData")
public Iterator<Object[]> loadFromCsv() throws IOException {
List<Object[]> data = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader("src/test/resources/price-cases.csv"))) {
String line;
reader.readLine(); // 跳过表头
while ((line = reader.readLine()) != null) {
String[] parts = line.split(",");
data.add(new Object[]{
parts[0].trim(),
Double.parseDouble(parts[1].trim()),
Double.parseDouble(parts[2].trim())
});
}
}
return data.iterator();
}分组执行:@Test(groups = ...)
TestNG 的分组功能让你可以灵活控制"哪些测试在什么时候跑"。
public class PaymentServiceTest {
@Test(groups = {"unit", "fast"})
public void testCalculateServiceFee() {
// 单元测试,运行快
BigDecimal fee = PaymentService.calculateServiceFee(new BigDecimal("1000"));
assertEquals(new BigDecimal("10.00"), fee);
}
@Test(groups = {"integration", "slow"})
public void testRealPaymentGateway() {
// 集成测试,需要真实环境,运行慢
PaymentResult result = paymentGateway.charge("test-card", new BigDecimal("100"));
assertTrue(result.isSuccess());
}
@Test(groups = {"smoke"})
public void testHealthCheck() {
// 冒烟测试,每次部署后快速验证
assertTrue(paymentService.isHealthy());
}
}在 testng.xml 里配置不同的运行套件:
<!-- 只跑单元测试:testng-unit.xml -->
<suite name="Unit Tests">
<test name="Fast Unit Tests">
<groups>
<run>
<include name="unit"/>
</run>
</groups>
<classes>
<class name="com.example.PaymentServiceTest"/>
</classes>
</test>
</suite>
<!-- 跑所有测试但排除慢测试:testng-ci.xml -->
<suite name="CI Tests">
<test name="CI Test Suite">
<groups>
<run>
<exclude name="slow"/>
</run>
</groups>
<packages>
<package name="com.example"/>
</packages>
</test>
</suite>在 Maven 中使用不同配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- CI 环境用 CI 套件 -->
<suiteXmlFiles>
<suiteXmlFile>${basedir}/src/test/resources/testng-ci.xml</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>依赖测试:dependsOnMethods 和 dependsOnGroups
这是 TestNG 独有的强大特性,在接口自动化测试场景里极其有用。
public class E2EOrderFlowTest {
private static Long createdOrderId;
private static String paymentToken;
@Test(groups = {"e2e"}, description = "步骤1:用户登录获取 Token")
public void step1_login() {
LoginResult result = authService.login("testuser", "password");
assertTrue(result.isSuccess());
paymentToken = result.getToken();
assertNotNull(paymentToken);
}
@Test(groups = {"e2e"},
dependsOnMethods = {"step1_login"},
description = "步骤2:创建订单")
public void step2_createOrder() {
CreateOrderRequest request = CreateOrderRequest.builder()
.token(paymentToken)
.productId(100L)
.quantity(2)
.build();
CreateOrderResult result = orderService.createOrder(request);
assertTrue(result.isSuccess());
createdOrderId = result.getOrderId();
assertNotNull(createdOrderId);
}
@Test(groups = {"e2e"},
dependsOnMethods = {"step2_createOrder"},
description = "步骤3:支付订单")
public void step3_payOrder() {
PaymentResult result = paymentService.pay(createdOrderId, paymentToken);
assertTrue(result.isSuccess());
assertEquals(PaymentStatus.SUCCESS, result.getStatus());
}
@Test(groups = {"e2e"},
dependsOnMethods = {"step3_payOrder"},
description = "步骤4:验证订单状态")
public void step4_verifyOrderStatus() {
Order order = orderService.getOrder(createdOrderId);
assertEquals(OrderStatus.PAID, order.getStatus());
assertNotNull(order.getPaymentTime());
}
}如果 step2_createOrder 失败了,step3_payOrder 和 step4_verifyOrderStatus 会自动被跳过(标记为 SKIP),而不是失败。这让测试报告更清晰——你知道是哪一步失败了,后续步骤是因为依赖失败而跳过的。
注意:dependsOnMethods 的适用场景
我要明确说:依赖测试不适合用在单元测试里。单元测试应该完全独立。
dependsOnMethods 主要用在:
- 端到端(E2E)流程测试
- 接口自动化测试(有状态的流程)
- 系统验收测试
用在单元测试里会让单元测试变得脆弱,一个失败会导致整串测试失败。
并行策略:让 2000 个用例跑得更快
TestNG 提供了细粒度的并行控制。
testng.xml 里配置并行
<suite name="Parallel Suite"
parallel="methods"
thread-count="10"
data-provider-thread-count="5">
<test name="Unit Tests" parallel="classes" thread-count="4">
<packages>
<package name="com.example.service"/>
</packages>
</test>
<test name="Integration Tests" parallel="none">
<!-- 集成测试不并行 -->
<packages>
<package name="com.example.integration"/>
</packages>
</test>
</suite>并行级别说明:
methods:方法级并行,每个@Test方法独立线程classes:类级并行,每个测试类独立线程tests:<test>标签级并行none:串行执行(默认)
DataProvider 的并行
// DataProvider 本身也支持并行
@DataProvider(name = "parallelData", parallel = true)
public Object[][] provideParallelData() {
return new Object[][] {
{"case1"}, {"case2"}, {"case3"}, {"case4"}
};
}
@Test(dataProvider = "parallelData")
public void testWithParallelData(String testCase) {
// 这些用例会并行执行
ApiResult result = apiClient.call(testCase);
assertNotNull(result);
}踩坑实录三则
踩坑一:dependsOnMethods 和 @DataProvider 结合使用时,依赖识别出错
现象:一个带 DataProvider 的方法被另一个方法 dependsOnMethods 依赖,但 TestNG 报找不到依赖。
原因:带 DataProvider 的测试方法名包含了参数信息,依赖时方法名要精确匹配。
解法:不要让带 DataProvider 的方法作为依赖目标;或者用 dependsOnGroups 替代 dependsOnMethods,给方法打 group 而不是直接依赖方法名。
踩坑二:并行测试里用了 ThreadLocal 但没清理,导致数据错乱
现象:并行执行时,某些测试的数据库查询结果不对,像是被其他测试"污染"了。
原因:测试里用了 ThreadLocal 存用户上下文,并行时不同测试共享同一个线程(线程池复用),上一个测试的 ThreadLocal 没清理,被下一个测试读到了。
解法:
@AfterMethod
public void cleanupThreadLocal() {
UserContext.clear();
RequestContext.clear();
}TestNG 的 @AfterMethod 相当于 JUnit 的 @AfterEach,在每个测试方法后执行。并行时每个线程各自执行 cleanup,可以解决问题。
踩坑三:testng.xml 里的 class 配置和包扫描冲突,同一个测试跑了两遍
现象:同一个测试方法在报告里出现了两次执行记录。
原因:testng.xml 里同时配置了 <class name="com.example.FooTest"/> 和 <package name="com.example"/>,导致 FooTest 被包含了两次。
解法:在一个 <test> 配置块里,要么用 <classes> 精确指定,要么用 <packages> 包扫描,不要混用。
JUnit 5 vs TestNG:怎么选
这是个高频问题,我的明确建议:
选 JUnit 5 的场景:
- Spring Boot 项目(Spring Test 对 JUnit 5 的整合更完善)
- 纯粹的单元测试
- 新项目、绿地项目
选 TestNG 的场景:
- 接口自动化测试、API 自动化(DataProvider + dependsOnMethods 更适合)
- 需要灵活的分组执行策略(多套测试套件,按环境/类型划分)
- 遗留项目本来就用 TestNG
不要混用:一个项目里同时用 JUnit 5 和 TestNG 会造成配置混乱,选一个就好。
