Skip to content
SDETRole
SDETRole

Lesson 7 of 9 · API Testing

REST Assured Basics

Write readable Java API tests with REST Assured 5: given/when/then, GPath body assertions with Hamcrest, specifications, extracting values and JSON Schema checks.

intermediate 6 min read Updated

You will learn to

  • Set up a Maven project with REST Assured 5 and JUnit 5
  • Write given/when/then tests that assert on status, headers and JSON bodies with Hamcrest
  • Remove duplication with RequestSpecBuilder and ResponseSpecBuilder
  • Extract values to chain requests and validate responses against a JSON Schema

Prerequisites

  • Java for Testers
  • HTTP Fundamentals

REST Assured is the standard library for API testing in Java. Its fluent given().when().then() style reads close to a test case written in English, it has powerful built-in JSON and XML navigation, and it plugs into any Java test runner. If your team’s services or UI framework are in Java, REST Assured is usually the natural choice for API tests.

The examples use REST Assured 5.x, JUnit 5, Java 21 and the SDETRole Practice API at https://sdetrole.com/api/mock.

Project setup

Add the dependencies to pom.xml (check Maven Central for the latest 5.x and JUnit versions):

<dependencies>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>5.5.0</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>json-schema-validator</artifactId>
    <version>5.5.0</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
  </dependency>
</dependencies>

REST Assured brings Hamcrest with it. If you want to serialise Java objects (POJOs or Maps) to JSON request bodies, also add com.fasterxml.jackson.core:jackson-databind; REST Assured detects it on the classpath automatically.

Given, when, then

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

import org.junit.jupiter.api.Test;

class UserApiTest {

    @Test
    void getsASingleUser() {
        given()
            .baseUri("https://sdetrole.com")
            .basePath("/api/mock")
            .accept("application/json")
        .when()
            .get("/users/1")
        .then()
            .statusCode(200)
            .contentType(containsString("application/json"))
            .header("ETag", equalTo("\"user-1-v1\""))
            .body("id", equalTo(1))
            .body("name", equalTo("Priya Shah"))
            .body("email", endsWith("@example.com"))
            .body("role", equalTo("tester"))
            .body("active", is(true));
    }
}
  • given: everything about the request: base URI, headers, query parameters, body, auth.
  • when: the method and path.
  • then: assertions on the response. Each body call takes a GPath expression and a Hamcrest matcher.

Every failed assertion prints the expected and actual value. Add .log().ifValidationFails() after then() to also print the full request and response when something fails, and nothing when it passes.

GPath: navigating JSON

GPath is Groovy’s path syntax. The root is the response body; dots step into objects; lists support Groovy collection methods. For GET /users?page=1&limit=10, which returns { "data": [...], "page": 1, "limit": 10, "total": 25, "totalPages": 3 }:

@Test
void listsUsersWithPagination() {
    given()
        .baseUri("https://sdetrole.com")
        .basePath("/api/mock")
        .queryParam("page", 1)
        .queryParam("limit", 10)
    .when()
        .get("/users")
    .then()
        .statusCode(200)
        .header("X-Total-Count", "25")
        .header("Link", containsString("rel=\"next\""))
        .body("data", hasSize(10))
        .body("total", equalTo(25))
        .body("data[0].name", equalTo("Priya Shah"))
        .body("data.id", hasItems(1, 2, 3))
        .body("data.role", everyItem(in(new String[] {"admin", "tester", "developer", "viewer"})))
        .body("data.findAll { it.role == 'admin' }.name", hasItem("Sofia Rossi"));
}
GPath Meaning
data The data array
data[0].name name of the first element
data.id A list of every element’s id
data.size() Number of elements
data.findAll { it.active == false } Elements matching a condition
data.find { it.id == 6 }.name First element matching a condition

JSON numbers with decimals are read as Float by default, which can make equalTo(39.99) fail against a Double. Either compare with equalTo(39.99f) or configure JsonConfig with NumberReturnType.BIG_DECIMAL for money.

Sending bodies

A Java text block is the simplest way to send JSON and needs no extra library:

@Test
void createsAUser() {
    String body = """
        {
          "name": "Test User",
          "email": "test.user.%d@example.com",
          "role": "tester"
        }
        """.formatted(System.currentTimeMillis());

    given()
        .baseUri("https://sdetrole.com")
        .basePath("/api/mock")
        .contentType("application/json")
        .body(body)
    .when()
        .post("/users")
    .then()
        .statusCode(201)
        .header("Location", matchesPattern("/api/mock/users/\\d+"))
        .body("id", notNullValue())
        .body("role", equalTo("tester"))
        .body("active", is(true));
}

With Jackson on the classpath you can pass a record or Map instead, and REST Assured serialises it based on the content type.

Specifications: removing duplication

Repeating base URI, content type and common assertions in every test is noise. Specifications capture them once:

import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.filter.log.LogDetail;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;

public final class Specs {
    public static final RequestSpecification PRACTICE_API = new RequestSpecBuilder()
            .setBaseUri(System.getenv().getOrDefault("API_BASE_URI", "https://sdetrole.com"))
            .setBasePath("/api/mock")
            .setContentType(ContentType.JSON)
            .setAccept(ContentType.JSON)
            .log(LogDetail.URI)
            .build();

    public static final ResponseSpecification PROBLEM_JSON = new ResponseSpecBuilder()
            .expectContentType("application/problem+json; charset=utf-8")
            .expectBody("type", startsWith("https://sdetrole.com/practice/api-playground#problem-"))
            .expectBody("title", notNullValue())
            .build();

    private Specs() {}
}

Tests then use .spec(...) on either side:

@Test
void unknownUserIs404() {
    given()
        .spec(Specs.PRACTICE_API)
    .when()
        .get("/users/9999")
    .then()
        .spec(Specs.PROBLEM_JSON)
        .statusCode(404)
        .body("status", equalTo(404))
        .body("detail", equalTo("No user with id 9999."));
}

Error responses from the Practice API use the application/problem+json media type (RFC 9457). If your REST Assured version cannot parse a body with that content type, register the JSON parser for it once, for example in a @BeforeAll method: RestAssured.registerParser("application/problem+json", Parser.JSON);.

Extracting values and chaining requests

Use extract() to pull values out of a response for the next request. Here is the login-then-profile flow:

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

class AuthApiTest {
    static String token;

    @BeforeAll
    static void logIn() {
        token = given()
                .spec(Specs.PRACTICE_API)
                .body("""
                    {"username": "tester", "password": "practice123"}
                    """)
            .when()
                .post("/auth/login")
            .then()
                .statusCode(200)
                .body("tokenType", equalTo("Bearer"))
                .extract()
                .path("accessToken");
    }

    @Test
    void returnsTheCurrentUser() {
        given()
            .spec(Specs.PRACTICE_API)
            .auth().oauth2(token) // sends Authorization: Bearer <token>
        .when()
            .get("/auth/me")
        .then()
            .statusCode(200)
            .body("username", equalTo("tester"))
            .body("scopes", hasItem("users:read"));
    }

    @Test
    void rejectsAnInvalidToken() {
        given()
            .spec(Specs.PRACTICE_API)
            .header("Authorization", "Bearer wrong-token")
        .when()
            .get("/auth/me")
        .then()
            .statusCode(401)
            .header("WWW-Authenticate", containsString("invalid_token"))
            .body("detail", equalTo("The access token is invalid."));
    }
}

extract().path(...) returns a single value; extract().response() returns the whole Response if you need several values; extract().as(User.class) deserialises into a class when Jackson is present. In real suites, read credentials from environment variables rather than hard-coding them.

JSON Schema validation

Value assertions check a few fields; a schema checks the shape of the whole response. Put the schema in src/test/resources/schemas/user.json:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "required": ["id", "name", "email", "role", "active", "createdAt"],
  "properties": {
    "id": { "type": "integer" },
    "name": { "type": "string", "minLength": 2 },
    "email": { "type": "string" },
    "role": { "enum": ["admin", "tester", "developer", "viewer"] },
    "active": { "type": "boolean" },
    "createdAt": { "type": "string" }
  },
  "additionalProperties": false
}
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;

@Test
void userMatchesSchema() {
    given().spec(Specs.PRACTICE_API)
    .when().get("/users/1")
    .then().statusCode(200)
        .body(matchesJsonSchemaInClasspath("schemas/user.json"));
}

REST Assured’s schema module is built on an older validator that implements JSON Schema draft-04, so declare draft-04 as above, avoid newer keywords, and check schemas with the JSON Schema Validator first.

Logging and debugging

When a test fails, the first question is always “what exactly was sent, and what came back?”. REST Assured has logging on both sides of the call:

given()
    .spec(Specs.PRACTICE_API)
    .log().all()                 // method, URI, headers and body of the request
.when()
    .get("/users/1")
.then()
    .log().ifValidationFails()   // the response, only when an assertion fails
    .statusCode(200);

For a whole suite, RestAssured.enableLoggingOfRequestAndResponseIfValidationFails() in a @BeforeAll method gives every test the same behaviour without repeating it. Two cautions apply. Full request logging prints Authorization headers and passwords, so restrict it to local debugging or use a blacklist of headers (LogConfig supports blacklisting sensitive header names). And for reports, prefer attaching the request and response of failing calls through a filter (an implementation of io.restassured.filter.Filter, added with .filter(...)) rather than scraping console output, which is lost when tests run in parallel.

Practice exercise

Using REST Assured, write tests against the Practice API for:

  1. GET /users?role=admin returns exactly 5 users, all with role admin, and no Link header (there is only one page).
  2. GET /users?limit=51 returns 400 with an invalidParams entry for limit.
  3. GET /products?category=mice returns 2 products, all in category mice, and at least one is out of stock.

Try the requests in the API Playground first and use the JSONPath Tester to explore the JSON.

Show a model answer
@Test
void filtersAdmins() {
    given().spec(Specs.PRACTICE_API).queryParam("role", "admin")
    .when().get("/users")
    .then()
        .statusCode(200)
        .header("Link", nullValue())
        .header("X-Total-Count", "5")
        .body("data", hasSize(5))
        .body("data.role", everyItem(equalTo("admin")))
        .body("totalPages", equalTo(1));
}

@Test
void rejectsLimitAboveFifty() {
    given().spec(Specs.PRACTICE_API).queryParam("limit", 51)
    .when().get("/users")
    .then()
        .spec(Specs.PROBLEM_JSON)
        .statusCode(400)
        .body("invalidParams.name", hasItem("limit"));
}

@Test
void filtersProductsByCategory() {
    given().spec(Specs.PRACTICE_API).queryParam("category", "mice")
    .when().get("/products")
    .then()
        .statusCode(200)
        .body("total", equalTo(2))
        .body("data.category", everyItem(equalTo("mice")))
        .body("data.inStock", hasItem(false));
}

The first test is the interesting one: asserting the absence of a header is as important as asserting its presence, because a client that follows rel="next" blindly would loop or request an empty page.

Key takeaways

  • REST Assured’s given().when().then() keeps request setup, action and assertions visually separate.
  • Body assertions use GPath plus Hamcrest matchers such as equalTo, hasItem, hasSize and everyItem.
  • Use RequestSpecBuilder and ResponseSpecBuilder instead of global static config to remove duplication.
  • extract().path() chains values such as tokens between requests; auth().oauth2(token) sends a bearer token.
  • Add JSON Schema validation for structure, and keep value assertions for the fields that carry business meaning.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →