Hamcrest Matchers for Assertions

9 min read

Rest Assured's body(path, matcher) chain takes Hamcrest matchers as its second argument. Hamcrest is a small assertion library, not a Rest Assured invention — it's also what JUnit and AssertJ-adjacent code reaches for under the hood. The big wins are readability (greaterThan(18) reads aloud like English) and failure messages ("Expected: a value greater than 18, but was 12" beats assertTrue(age > 18) by a mile). This lesson is the matcher set you'll lean on hardest, organised by category, with the small handful of pitfalls that catch every newcomer.

The single import that unlocks everything

import static org.hamcrest.Matchers.*;

Once that's in place, every matcher below is callable bare — no Matchers.equalTo, just equalTo. Add it to your shared base class so you only ever import it once.

Equality — the workhorse

.body("name", equalTo("Alice"))      // exact match
.body("name", is("Alice"))            // identical to equalTo, more readable
.body("name", not(equalTo("Bob")))    // negation
.body("status", is(not("inactive"))) // double-readable form

is() is a syntactic sugar: is(equalTo(x)) and is(x) both compile to the same matcher. Use is when it makes the line read better. not(...) wraps any matcher to invert it.

String matchers

Rest Assured tests are saturated with string matching — emails, names, formatted dates. Hamcrest's string toolkit:

.body("email", containsString("@test.com"))
.body("email", startsWith("alice"))
.body("email", endsWith(".com"))
.body("name", matchesPattern("^[A-Z][a-z]+$"))           // anchored regex
.body("description", isEmptyOrNullString())               // "" or null
.body("name", not(emptyString()))                         // not ""
.body("greeting", equalToIgnoringCase("HELLO"))
.body("phone", matchesPattern("^\\+?\\d{7,15}$"))

matchesPattern is a regex matcher; the pattern is a Java regex (note the doubled backslashes). For values whose exact text changes between runs (timestamps, generated IDs), pin the shape with a regex rather than the text.

Number matchers

.body("age", greaterThan(18))
.body("age", greaterThanOrEqualTo(21))
.body("price", lessThan(100.0f))
.body("score", closeTo(85.0, 0.5))   // within 0.5 of 85.0
.body("count", both(greaterThan(0)).and(lessThan(1000)))

closeTo is the floating-point matcher you've been wanting your whole career — it bakes the tolerance into the assertion instead of you eyeballing Math.abs(a - b) < epsilon. Use it for any computed numeric value (averages, percentages, prices in non-decimal currency).

both(x).and(y) is shorthand for allOf(x, y) and reads better when there are exactly two conditions.

Collection and array matchers

.body("users", hasSize(10))                              // exact length
.body("users", hasSize(greaterThan(0)))                  // size + matcher
.body("users.name", hasItem("Alice"))                    // contains this item
.body("users.name", hasItems("Alice", "Bob"))            // contains all of these
.body("users.name", not(hasItem("Charlie")))             // does NOT contain
.body("tags", everyItem(not(emptyString())))             // every item is non-empty
.body("roles", containsInAnyOrder("admin", "tester", "viewer"))  // exactly these, any order
.body("roles", contains("admin", "tester"))              // exactly these, in this order

The two often-confused matchers are hasItems (a subset check — these are present, others may also be) versus containsInAnyOrder (an exact-set check — these and only these). When you assert a fixed-size enum response (a list of valid roles), use containsInAnyOrder; when you assert that a query returned at least the things you expected, use hasItems.

Null matchers

.body("id", notNullValue())
.body("deletedAt", nullValue())
.body("trace", anyOf(nullValue(), instanceOf(String.class)))

A field that doesn't exist in the JSON resolves to null in Rest Assured — nullValue() matches both missing and explicit null. If you need to tell them apart, drop into raw JSON inspection with extract().asString() and use a JSON library.

Combining matchers

allOf and anyOf are the boolean operators of the matcher world:

.body("price", allOf(greaterThan(0.0f), lessThan(1000.0f)))     // AND
.body("status", anyOf(is("active"), is("pending")))               // OR
.body("email", allOf(containsString("@"), endsWith(".com")))
.body("role", not(equalTo("superadmin")))                         // never

allOf fails on the first child matcher that fails, so put the cheapest checks first if perf matters (it rarely does in tests).

Type checking

.body("id", instanceOf(Integer.class))
.body("name", instanceOf(String.class))
.body("active", instanceOf(Boolean.class))
.body("price", anyOf(instanceOf(Integer.class), instanceOf(Double.class)))

JSON has only one numeric type, but Jackson maps it to the narrowest Java type that fits — 42 becomes Integer, 42.0 becomes Double. The anyOf form is what you reach for when an API is inconsistent.

The matcher categories at a glance

Memorise the column 1 list — that's roughly 95% of the matchers you'll ever type. Everything else (hasProperty, hasEntry, samePropertyValuesAs) is for unit-testing Java objects, not JSON responses.

Custom failure messages

When a matcher fails inside a long chain, Rest Assured tells you what was expected and what it actually got. For non-obvious assertions, describedAs adds a hint:

.body("users.size()",
    describedAs("Expected at least 5 users (smoke baseline) but got %0",
        greaterThanOrEqualTo(5)))

The %0 placeholder is replaced with the actual value at failure time. Use this sparingly — most matchers produce good messages on their own — but it's the difference between a confused junior and a 30-second triage when the message is something like "size 3 was less than 5" versus "smoke baseline expected 5 users; got 3 — likely DB seed regression."

Building tiny custom matchers

When you find yourself writing the same allOf(greaterThan(0), lessThan(120)) for every age field, factor it into a method:

private static Matcher<Integer> isReasonableAge() {
    return allOf(greaterThan(0), lessThan(120));
}
 
// Use it
.body("user.age", isReasonableAge())
.body("admin.age", isReasonableAge());

The codebase pays back the tiny investment every time the rule changes (raise the upper bound to 150 once, fix every test). Chapter 5 takes this further with full domain matchers.

⚠️ Common mistakes

  • Using containsInAnyOrder when you meant hasItems. containsInAnyOrder("a","b") fails if the array has a third element. hasItems("a","b") doesn't care about extras. The former is a contract test; the latter is a presence test. Pick deliberately.
  • Asserting on equalTo for floats. equalTo(0.1f + 0.2f) won't match 0.3f because of floating-point representation. Use closeTo(0.3, 0.0001) for any non-integer numeric.
  • Pinning equalTo on values that change between runs. Generated IDs, timestamps, and any UUID will fail your second test run. Use notNullValue(), instanceOf(...), or matchesPattern(...) for shape-only assertions when the value isn't stable.

🎯 Practice task

Drill the matcher set against JSONPlaceholder. 25–35 minutes.

  1. getUsersHasTen() — assert body("size()", equalTo(10)). Run green.
  2. everyUserHasEmailWithAt()body("email", everyItem(containsString("@"))). Run green.
  3. Number range. body("id", everyItem(allOf(greaterThan(0), lessThanOrEqualTo(10)))). Confirm.
  4. Regex on phone. Assert that every user's phone matches ^[\\d\\s().+x-]+$. Note the doubled backslashes inside the Java string.
  5. containsInAnyOrder vs hasItems. Get /users, assert that username for the first three users containsInAnyOrder("Bret","Antonette","Samantha") — then change one to a wrong name and watch the failure message. Switch to hasItems and watch the same test pass (because the matcher tolerates extras). Internalise the difference.
  6. Combine matchers. Add body("address.zipcode", matchesPattern("^[\\d-]+$")) and body("address.geo.lat", instanceOf(String.class)). Note that JSONPlaceholder returns lat/lng as strings — a real-world surprise.
  7. describedAs. Wrap one assertion with describedAs("Smoke regression: at least 10 users", hasSize(greaterThanOrEqualTo(10))). Force it to fail. Read the upgraded failure message.
  8. Stretch: write a private helper isValidEmail() that returns Matcher<String> combining containsString("@"), not(emptyString()), and matchesPattern("^[^@]+@[^@]+\\.[^@]+$"). Use it across three tests. Note the readability win.

Next lesson: JSON Schema validation — the contract-level test that catches structural changes (a removed field, a renamed key) before any of these field-level matchers do.

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