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
bodycall 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:
GET /users?role=adminreturns exactly 5 users, all with roleadmin, and noLinkheader (there is only one page).GET /users?limit=51returns400with aninvalidParamsentry forlimit.GET /products?category=micereturns 2 products, all in categorymice, 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,hasSizeandeveryItem. - Use
RequestSpecBuilderandResponseSpecBuilderinstead 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.
Practise it
Related interview questions
- Explain REST Assured's given/when/then structure and write a basic test
- What do you validate in an API response beyond the status code?
- How do you write tests with pm.test and pm.expect, and chain requests in Postman?
- How do RequestSpecification and ResponseSpecification help you avoid duplication in REST Assured?