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}$")); // regexequalTo 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 stringCustom 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)); // millisecondsA 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
What to validate on every response
| Common matchers | Example | Why it matters | |
|---|---|---|---|
| Status | statusCode(200), anyOf(is(200), is(201)) | .statusCode(201).statusLine(containsString("Created")) | First signal of success — but never the only one |
| Body | equalTo, containsString, hasItem, hasSize, matchesPattern, notNullValue | .body("users.size()", greaterThan(0)).body("users[0].name", equalTo("Alice")) | Where contracts live — fields, types, shape, content |
| Headers | header(name, matcher), contentType(...) | .header("X-Request-Id", notNullValue()).contentType(ContentType.JSON) | Tracing, caching, content negotiation — quietly important |
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 OKbody 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 whoseContent-Typeistext/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.
- In
UserApiTest, writegetUserOneReturnsCompleteValidUser()using JSONPlaceholder. Validate status, content type, three body fields, at least one nested field, and a regex match onaddress.zipcode. Run it green. - Write
getAllUsersHasTenWithUniqueEmails(). Assert there are exactly 10 users; every user has a non-null email; every email contains@. Useevery,hasSize(10), andallOfas needed. - 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. - Force one assertion to fail. Change one
equalToto 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. - Header check. Add
.header("Content-Type", containsString("application/json"))to one of your tests. Force it to fail by assertingequalTo("application/xml"). Read the message; restore. - 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. - 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.