Response Validation — Status Code, Body, Headers

9 min read

A response has three things to validate: the status code, the body, and the headers. The API Testing Masterclass lesson on HTTP methods, status codes, and headers taught you what each of these means. This lesson teaches you to assert on them in Rest Assured — including the Hamcrest matchers that handle exact matches, ranges, regexes, and array shapes, plus the JsonPath expressions that walk into nested JSON. By the end you'll be able to write a single fluent chain that validates a dozen things about a response.

Status code validation

The simplest case — exact match:

.then()
    .statusCode(200);

Rest Assured also accepts Hamcrest matchers, which is how you express "anything in the 2xx range":

.then()
    .statusCode(anyOf(is(200), is(201)))
    .statusLine(containsString("OK"));

A test that needs to accept multiple success codes (e.g., a POST that some APIs return as 200 and some as 201) shouldn't pin to one — anyOf(is(200), is(201)) keeps the test honest. For cleanly bracketing a class, statusCode(both(greaterThanOrEqualTo(200)).and(lessThan(300))) works but is rarely worth the verbosity; pick the specific codes you care about.

Body validation with Hamcrest

The body() method takes a JsonPath expression and a Hamcrest matcher. The matcher set you'll lean on hardest:

.then()
    .body("name", equalTo("Alice"))                      // exact match
    .body("email", containsString("@test.com"))           // partial match
    .body("role", is(oneOf("admin", "tester", "viewer"))) // enum-style check
    .body("age", greaterThan(18))                         // numeric comparison
    .body("id", notNullValue())                           // not null
    .body("deletedAt", nullValue())                       // is null
    .body("tags", hasSize(3))                             // collection size
    .body("tags", hasItem("automation"))                  // contains element
    .body("tags", hasItems("java", "qa"))                 // contains all
    .body("address.city", equalTo("London"))              // nested field
    .body("phone", matchesPattern("^\\+?\\d{7,15}$"));    // regex

equalTo is exact equality. containsString does substring matching. oneOf is the readable shorthand for "any of these values." matchesPattern takes a regex. hasItem and hasItems cover the common array-membership cases. None of this is Rest Assured-specific — it's vanilla Hamcrest, and once you know it, you'll reach for the same matchers in unit tests too.

Walking into nested JSON

Rest Assured's body() accepts a JsonPath expression — a dotted path that walks into objects and arrays:

// For a body like { "user": { "address": { "postcode": "SW1A 1AA" } } }
.body("user.address.postcode", equalTo("SW1A 1AA"))

For arrays you can index by position or use a Groovy GPath expression for collection operations:

// { "users": [{"name": "Alice"}, {"name": "Bob"}] }
.body("users[0].name", equalTo("Alice"))                    // first
.body("users[-1].name", equalTo("Bob"))                     // last (negative index)
.body("users.size()", equalTo(2))                           // size
.body("users.name", hasItem("Alice"))                       // any user named Alice
.body("users.findAll { it.role == 'admin' }.size()", equalTo(2))   // GPath: 2 admins
.body("users.collect { it.email }", everyItem(containsString("@")))  // every email has @

The findAll, collect, and it syntax is Groovy — Rest Assured embeds a Groovy engine for these expressions. You don't need to write Groovy elsewhere; just know that when you see { it.role == 'admin' }, that's Groovy filtering inside a JsonPath.

Header validation

Server responses always have headers — content type, caching, custom tracing IDs. Validate the ones that matter:

.then()
    .header("Content-Type", containsString("application/json"))
    .header("X-Request-Id", notNullValue())
    .header("Cache-Control", equalTo("no-cache"));

Content-Type deserves a dedicated method because it's so common:

.then()
    .contentType(ContentType.JSON)                  // ContentType enum
    .contentType("application/json; charset=utf-8")  // exact string

Custom headers are how teams track requests through logs (X-Request-Id, X-Correlation-Id). Asserting they're present in test environments catches regressions where a service stops emitting them.

Response time

Slow responses are bugs even when the body is right. Rest Assured exposes the elapsed time directly in the assertion chain:

.then()
    .time(lessThan(2000L));   // milliseconds

A word of caution: response times in tests are noisy. Network jitter, cold starts, and shared CI runners all push the number around. Use a generous threshold, or better, bake the SLO into a separate performance test. A 200 ms target in a functional test will eventually flake on a slow morning.

A complete validation chain

Putting it all together — a single test that validates status, content type, headers, multiple body fields, an array shape, a regex, and response time:

@Test
public void getUserOneReturnsCompleteValidUser() {
    given()
        .header("Accept", "application/json")
    .when()
        .get("/users/1")
    .then()
        .statusCode(200)
        .contentType(ContentType.JSON)
        .header("Content-Type", containsString("application/json"))
        .body("id", equalTo(1))
        .body("name", equalTo("Leanne Graham"))
        .body("email", endsWith("@april.biz"))
        .body("address.city", notNullValue())
        .body("address.zipcode", matchesPattern("^[\\d-]+$"))
        .body("company.name", instanceOf(String.class))
        .time(lessThan(3000L));
}

Eleven assertions in one fluent chain. Each assertion runs even if a prior one fails — Rest Assured collects every failure and reports them together, which is much friendlier than the typical "stop at first error" behaviour.

Three categories at a glance

The pattern: always assert status, always assert at least one body field, assert headers when they're part of the contract. Skipping any of these is how subtle bugs reach production.

Combining matchers — allOf and anyOf

Sometimes one assertion needs to combine matchers. allOf (every matcher must pass) and anyOf (one is enough) are the workhorses:

.body("age", allOf(greaterThan(18), lessThan(120)))
.body("status", anyOf(equalTo("active"), equalTo("pending")))
.body("email", allOf(containsString("@"), endsWith(".com")))

These read like English. They also play nicely with negation via not(...):

.body("password", not(emptyOrNullString()))
.body("role", not(equalTo("superadmin")))

Test data shouldn't leak the superadmin role; one line catches it.

When the JsonPath gets ugly

Sometimes a body's structure makes the path expression hard to read. Two escape hatches:

Pull the value out and assert in Java. When the chain becomes opaque, extract() lets you bind the value to a typed variable and assert on it explicitly:

List<String> emails = given().when().get("/users")
    .then()
        .statusCode(200)
        .extract()
        .jsonPath()
        .getList("email", String.class);
 
Assert.assertTrue(emails.stream().allMatch(e -> e.contains("@")));

Bind the response to a POJO. Chapter 5 covers this in depth — extract().as(User.class) deserialises the body into a typed object you can assert on with normal getters. Often the cleanest option for non-trivial responses.

⚠️ Common mistakes

  • Asserting only the status code. A 200 OK body containing {"error": "User not found"} is a real and common bug — the kind that's worse than a 500 because the test passes. Always pair status with at least one body assertion.
  • Pinning a flaky value. Tests that assert body("createdAt", equalTo("2024-01-15T10:00:00Z")) will fail on the next run. Pin the shape (matchesPattern("^\\d{4}-\\d{2}-\\d{2}T")) or the type (instanceOf(String.class)), not the exact value, when the value changes between runs.
  • Skipping .contentType() on the request, then asserting it on the response. If the server enforces content negotiation, your POST without a content type may return an error response whose Content-Type is text/plain — and your assertion .contentType(ContentType.JSON) will fail with a confusing message. Always set the content type on the request before asserting it on the response.

🎯 Practice task

Stack assertions until you're comfortable with the matcher set. 30–40 minutes.

  1. In UserApiTest, write getUserOneReturnsCompleteValidUser() using JSONPlaceholder. Validate status, content type, three body fields, at least one nested field, and a regex match on address.zipcode. Run it green.
  2. Write getAllUsersHasTenWithUniqueEmails(). Assert there are exactly 10 users; every user has a non-null email; every email contains @. Use every, hasSize(10), and allOf as needed.
  3. Use a GPath collection expression. Add an assertion: body("findAll { it.address.city.startsWith('S') }.size()", greaterThanOrEqualTo(1)). Read the Groovy filter syntax until it makes sense — it's the most powerful JsonPath feature.
  4. Force one assertion to fail. Change one equalTo to a wrong value. Note that Rest Assured runs every assertion before reporting; the failure message lists all the broken expectations together. This is a real-world win over stop-at-first-error frameworks.
  5. Header check. Add .header("Content-Type", containsString("application/json")) to one of your tests. Force it to fail by asserting equalTo("application/xml"). Read the message; restore.
  6. Response time. Add .time(lessThan(3000L)) to two tests. Run on a fast network and a slow network (e.g., a phone hotspot) — feel the flakiness. This is why functional and performance assertions belong in different suites.
  7. Stretch: find an open API of your choice (REQRES, PokeAPI, JSONPlaceholder) and write one test of your own. Pick one endpoint and make at least eight independent assertions about its response.

Next chapter: request building in depth — path/query parameters, headers and content types, JSON bodies, and file uploads. Everything that comes before the network call.

// tip to track lessons you complete and pick up where you left off across devices.