REST Assured with TestNG: Structuring API Test Suites

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, auth and 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=smoke

Data 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 thread
  • parallel="classes" — each test class runs in its own thread
  • parallel="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.

Read more