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.
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 test -DsuiteXmlFile=testng.xml
# Smoke only
mvn test -DsuiteXmlFile=testng-smoke.xml
# Or pass groups directly
mvn 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.