REST Assured with TestNG: Structuring API Test Suites
While JUnit is the default choice for most Java testing, TestNG offers compelling features for API test suites specifically. Test groups, flexible data providers, built-in parallel execution, and dependency management between tests make TestNG well-suited for organizing large API test collections. This guide covers using REST Assured with TestNG effectively.
Why TestNG for API Testing?
JUnit 5 is excellent for unit testing. TestNG has advantages in the API testing context:
- Test groups — tag tests as
smoke,regression,integration,authand run subsets - Dependencies — declare that test B must pass before test C runs
- Data providers — parameterized tests with cleaner syntax
- Suite XML — declarative test organization without code changes
- Parallel execution — built-in per-class, per-method, or per-test parallelism
- Listeners — hook into test lifecycle for reporting and logging
- Priority — control test execution order within a class
Setup
Add TestNG and REST Assured to your project:
Maven
<dependencies>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.9.0</version>
<scope>test</scope>
</dependency>
<!-- For TestNG XML suite execution -->
<dependency>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>testng.xml</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
</plugins>
</build>Your First TestNG API Test
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
public class UserApiTest {
@BeforeClass
public void setup() {
RestAssured.baseURI = "https://jsonplaceholder.typicode.com";
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
@Test
public void shouldGetAllUsers() {
given()
.when()
.get("/users")
.then()
.statusCode(200)
.body("size()", equalTo(10))
.body("[0].id", notNullValue());
}
@Test
public void shouldGetUserById() {
given()
.when()
.get("/users/1")
.then()
.statusCode(200)
.body("id", equalTo(1))
.body("name", equalTo("Leanne Graham"));
}
@Test
public void shouldReturn404ForMissingUser() {
given()
.when()
.get("/users/9999")
.then()
.statusCode(404);
}
}Run with TestNG: mvn test
Test Groups
Groups are TestNG's killer feature for API testing. Tag each test with one or more groups:
public class UserApiTest {
@Test(groups = {"smoke", "regression"})
public void shouldGetAllUsers() {
when().get("/users").then().statusCode(200);
}
@Test(groups = {"regression", "auth"})
public void shouldRequireAuthForProtectedEndpoint() {
when().get("/users/profile").then().statusCode(401);
}
@Test(groups = {"regression", "crud"})
public void shouldCreateUser() {
given()
.contentType(ContentType.JSON)
.body("{\"name\":\"Test\",\"email\":\"test@example.com\"}")
.when()
.post("/users")
.then()
.statusCode(201);
}
@Test(groups = {"smoke"})
public void shouldReturnOkForHealthCheck() {
when().get("/health").then().statusCode(200);
}
}TestNG XML Configuration
testng.xml lets you define which groups to run without changing code:
<!-- testng.xml — run all tests -->
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="API Tests" verbose="2">
<test name="Full Regression">
<groups>
<run>
<include name="regression"/>
</run>
</groups>
<classes>
<class name="com.example.api.UserApiTest"/>
<class name="com.example.api.OrderApiTest"/>
<class name="com.example.api.AuthApiTest"/>
</classes>
</test>
</suite><!-- testng-smoke.xml — smoke tests only -->
<suite name="Smoke Tests">
<test name="Smoke">
<groups>
<run>
<include name="smoke"/>
</run>
</groups>
<packages>
<package name="com.example.api"/>
</packages>
</test>
</suite>Run specific suites:
# Full regression
mvn <span class="hljs-built_in">test -DsuiteXmlFile=testng.xml
<span class="hljs-comment"># Smoke only
mvn <span class="hljs-built_in">test -DsuiteXmlFile=testng-smoke.xml
<span class="hljs-comment"># Or pass groups directly
mvn <span class="hljs-built_in">test -Dgroups=smokeData Providers
TestNG's @DataProvider is cleaner than JUnit's parameterized tests for API testing:
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
public class UserSearchTest {
@DataProvider(name = "validUsers")
public Object[][] validUserData() {
return new Object[][] {
{ 1, "Leanne Graham", "Sincere@april.biz" },
{ 2, "Ervin Howell", "Shanna@melissa.tv" },
{ 3, "Clementine Bauch", "Nathan@yesenia.net" },
};
}
@Test(dataProvider = "validUsers", groups = "regression")
public void shouldGetUserById(int id, String expectedName, String expectedEmail) {
given()
.when()
.get("/users/" + id)
.then()
.statusCode(200)
.body("name", equalTo(expectedName))
.body("email", equalTo(expectedEmail));
}
@DataProvider(name = "invalidIds")
public Object[][] invalidIdData() {
return new Object[][] {
{ 0 },
{ -1 },
{ 9999 },
{ Integer.MAX_VALUE },
};
}
@Test(dataProvider = "invalidIds", groups = "regression")
public void shouldReturn404ForInvalidId(int invalidId) {
given()
.when()
.get("/users/" + invalidId)
.then()
.statusCode(404);
}
}External Data Provider
Load test data from CSV or JSON:
import java.io.FileReader;
import java.util.ArrayList;
import java.util.List;
public class AuthTest {
@DataProvider(name = "credentialsFromFile")
public Object[][] loadCredentials() throws Exception {
List<Object[]> data = new ArrayList<>();
try (var reader = new FileReader("src/test/resources/test-credentials.csv")) {
// CSV: email,password,expectedStatus
new com.opencsv.CSVReader(reader).readAll().forEach(row -> {
data.add(new Object[]{row[0], row[1], Integer.parseInt(row[2])});
});
}
return data.toArray(new Object[0][]);
}
@Test(dataProvider = "credentialsFromFile", groups = "auth")
public void shouldHandleLoginAttempts(String email, String password, int expectedStatus) {
given()
.contentType(ContentType.JSON)
.body(String.format("{\"email\":\"%s\",\"password\":\"%s\"}", email, password))
.when()
.post("/api/auth/login")
.then()
.statusCode(expectedStatus);
}
}Test Dependencies
TestNG lets you declare that a test depends on another:
public class UserLifecycleTest {
private static int createdUserId;
@Test(groups = "crud")
public void shouldCreateUser() {
createdUserId = given()
.contentType(ContentType.JSON)
.body("{\"name\":\"Alice\",\"email\":\"alice@example.com\"}")
.when()
.post("/api/users")
.then()
.statusCode(201)
.extract()
.path("id");
assertThat(createdUserId, greaterThan(0));
}
@Test(dependsOnMethods = "shouldCreateUser", groups = "crud")
public void shouldGetCreatedUser() {
given()
.when()
.get("/api/users/" + createdUserId)
.then()
.statusCode(200)
.body("name", equalTo("Alice"));
}
@Test(dependsOnMethods = "shouldGetCreatedUser", groups = "crud")
public void shouldUpdateUser() {
given()
.contentType(ContentType.JSON)
.body("{\"name\":\"Alice Smith\"}")
.when()
.put("/api/users/" + createdUserId)
.then()
.statusCode(200)
.body("name", equalTo("Alice Smith"));
}
@Test(dependsOnMethods = "shouldUpdateUser", groups = "crud")
public void shouldDeleteUser() {
given()
.when()
.delete("/api/users/" + createdUserId)
.then()
.statusCode(204);
}
@Test(dependsOnMethods = "shouldDeleteUser", groups = "crud")
public void shouldReturn404AfterDeletion() {
given()
.when()
.get("/api/users/" + createdUserId)
.then()
.statusCode(404);
}
}If shouldCreateUser fails, all dependent tests are skipped — a much cleaner behavior than tests that fail with null pointer exceptions because setup didn't complete.
Parallel Test Execution
TestNG's parallel execution is a major advantage over JUnit for API testing:
<!-- testng-parallel.xml -->
<suite name="Parallel Tests" parallel="methods" thread-count="5">
<test name="API Tests">
<classes>
<class name="com.example.api.UserApiTest"/>
<class name="com.example.api.OrderApiTest"/>
<class name="com.example.api.ProductApiTest"/>
</classes>
</test>
</suite>Parallel options:
parallel="methods"— each test method runs in its own threadparallel="classes"— each test class runs in its own threadparallel="tests"— each<test>element in XML runs in its own thread
For safe parallel execution, avoid shared mutable state:
// BAD — shared mutable state
public class UserApiTest {
private static int lastCreatedId; // Race condition in parallel
@Test
void shouldCreateUser() {
lastCreatedId = createUser();
}
@Test
void shouldGetUser() {
getUser(lastCreatedId); // Might use wrong ID in parallel
}
}
// GOOD — thread-local state
public class UserApiTest {
private ThreadLocal<Integer> lastCreatedId = new ThreadLocal<>();
@Test
void shouldCreateUser() {
lastCreatedId.set(createUser());
}
@Test(dependsOnMethods = "shouldCreateUser")
void shouldGetUser() {
getUser(lastCreatedId.get()); // Safe in parallel
}
}TestNG Listeners
Listeners hook into test lifecycle for custom behavior:
import org.testng.ITestListener;
import org.testng.ITestResult;
import io.restassured.RestAssured;
public class ApiTestListener implements ITestListener {
@Override
public void onTestStart(ITestResult result) {
System.out.println("\n=== Starting: " + result.getMethod().getMethodName() + " ===");
}
@Override
public void onTestSuccess(ITestResult result) {
System.out.println("✓ PASSED: " + result.getMethod().getMethodName());
}
@Override
public void onTestFailure(ITestResult result) {
System.out.println("✗ FAILED: " + result.getMethod().getMethodName());
System.out.println(" Error: " + result.getThrowable().getMessage());
}
@Override
public void onTestSkipped(ITestResult result) {
System.out.println("⊘ SKIPPED: " + result.getMethod().getMethodName());
if (result.getThrowable() != null) {
System.out.println(" Reason: " + result.getThrowable().getMessage());
}
}
}Register in testng.xml:
<suite name="API Tests">
<listeners>
<listener class-name="com.example.listeners.ApiTestListener"/>
</listeners>
<test name="Regression">
<classes>
<class name="com.example.api.UserApiTest"/>
</classes>
</test>
</suite>Or via annotation:
@Listeners(ApiTestListener.class)
public class UserApiTest {
// tests
}Base Test Class
Centralize setup and shared utilities:
import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.specification.RequestSpecification;
import org.testng.annotations.BeforeSuite;
public abstract class BaseApiTest {
protected static RequestSpecification baseSpec;
protected static RequestSpecification authSpec;
@BeforeSuite(alwaysRun = true)
public void setupSuite() {
String baseUrl = System.getProperty("api.base.url", "https://api.example.com");
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
baseSpec = new RequestSpecBuilder()
.setBaseUri(baseUrl)
.setContentType(ContentType.JSON)
.setAccept(ContentType.JSON)
.build();
String token = obtainAuthToken();
authSpec = new RequestSpecBuilder()
.addRequestSpecification(baseSpec)
.addHeader("Authorization", "Bearer " + token)
.build();
}
private static String obtainAuthToken() {
return given()
.spec(baseSpec)
.contentType(ContentType.JSON)
.body(String.format("{\"email\":\"%s\",\"password\":\"%s\"}",
System.getenv("TEST_EMAIL"),
System.getenv("TEST_PASSWORD")))
.when()
.post("/auth/login")
.then()
.statusCode(200)
.extract()
.path("token");
}
protected int createUser(String name, String email) {
return given()
.spec(authSpec)
.body(String.format("{\"name\":\"%s\",\"email\":\"%s\"}", name, email))
.when()
.post("/users")
.then()
.statusCode(201)
.extract()
.path("id");
}
}Running TestNG Tests in CI
# .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [main]
pull_request:
jobs:
smoke:
name: Smoke Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- run: mvn test -DsuiteXmlFile=testng-smoke.xml
env:
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
API_BASE_URL: ${{ vars.STAGING_API_URL }}
regression:
name: Regression Tests
needs: smoke # Only run if smoke tests pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- run: mvn test -DsuiteXmlFile=testng.xml
env:
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
API_BASE_URL: ${{ vars.STAGING_API_URL }}
- name: Publish Test Report
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: '**/surefire-reports/TEST-*.xml'Summary
TestNG's feature set maps well to API testing needs:
| Feature | Use Case |
|---|---|
groups |
Run smoke vs. regression separately in CI |
dependsOnMethods |
Ordered CRUD test chains |
@DataProvider |
Parameterized auth and input testing |
parallel |
Faster test execution across endpoints |
@Listeners |
Custom reporting and failure logging |
| Suite XML | Multiple test configurations without code changes |
For teams already in the Java ecosystem, TestNG + REST Assured is a powerful combination. The group-based organization scales naturally as the API surface grows — add new groups like performance, security, or contract without restructuring existing tests.
Pair with REST Assured's Spring Boot integration for controller-level testing, and authentication patterns for comprehensive API coverage.