Serialisation turns a Java object into JSON for sending. Deserialisation turns JSON back into a Java object for asserting on. The wins mirror the previous lesson: typed access (user.getEmail() not body("email", ...)), compile-time refactor safety, and the same POJO doing double duty as both request and response model. Rest Assured exposes deserialisation via extract().as(Class) for single objects and jsonPath().getList(path, Class) for arrays. Once you reach for these, you'll find half your test code stops dealing with raw paths and matchers and starts looking like normal Java.
Deserialising to a single POJO
User user = given()
.when()
.get("/users/1")
.then()
.statusCode(200)
.extract()
.as(User.class);
Assert.assertEquals(user.getName(), "Alice");
Assert.assertEquals(user.getEmail(), "alice@test.com");
Assert.assertEquals(user.getRole(), "admin");extract().as(User.class) reads the entire response body and asks Jackson to construct a User from it. Jackson matches JSON keys to POJO setters using the same property conventions as the previous lesson (with @JsonProperty, @JsonInclude, etc. all applying symmetrically).
The crucial requirement Jackson has on the receiving end: a no-args constructor. Jackson constructs the empty object first, then calls setters one at a time. Without public User() {}, deserialisation fails at runtime with a clear (but easy-to-skip) error.
Deserialising a list
For root-level array responses:
List<User> users = given()
.when()
.get("/users")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getList("", User.class); // "" = root
Assert.assertEquals(users.size(), 10);
Assert.assertTrue(users.stream().anyMatch(u -> u.getName().equals("Alice")));The empty string in getList("", User.class) says the array is at the root; for nested arrays it'd be the path to the array, e.g. getList("data", User.class) for { "data": [...] }.
Once you have a List<User>, the full power of the Java Streams API is yours — filtering, mapping, counting:
long admins = users.stream()
.filter(u -> u.getRole().equals("admin"))
.count();
Assert.assertEquals(admins, 1);
List<String> emails = users.stream()
.map(User::getEmail)
.toList();
Assert.assertTrue(emails.stream().allMatch(e -> e.contains("@")));These checks are perfectly possible with raw JsonPath GPath expressions, but the typed form is more discoverable, easier to debug, and reuses the assertions you'd write in unit tests.
Nested objects come along for the ride
When the POJO has nested types, Jackson populates them recursively:
public class User {
private String name;
private String email;
private Address address; // nested POJO
private List<String> tags; // simple list
private List<Order> orders; // list of POJOs
// getters/setters/no-args ctor
}
public class Address {
private String street;
private String city;
private String postcode;
// getters/setters/no-args ctor
}
User user = response.extract().as(User.class);
Assert.assertEquals(user.getAddress().getCity(), "London");
Assert.assertEquals(user.getAddress().getPostcode(), "NW1 6XE");
Assert.assertEquals(user.getOrders().size(), 3);
Assert.assertEquals(user.getOrders().get(0).getTotal(), 99.99);Every nested type just needs the same getters/setters/no-args contract. The compiler walks alongside Jackson — getter chains read like a sentence: the user's address's city.
Tolerating unknown fields
Real APIs add fields. Without protection, your test class breaks the moment the API team adds a lastLoginAt:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class User {
private String name;
private String email;
// ... only the fields we care about
}@JsonIgnoreProperties(ignoreUnknown = true) says ignore any JSON field I don't have a setter for. Apply it to every response POJO. The alternative — having tests fail when the API legitimately evolves — is bad engineering. (For contract testing, JSON Schema validation from Chapter 3 is the right tool; deserialisation should be lenient.)
Wrapper responses and getObject
Many APIs wrap the actual payload:
{
"data": { "id": 1, "name": "Alice", "email": "alice@test.com" },
"message": "success",
"trace": "abc123"
}Two ways to handle it.
Option A — a wrapper POJO with generics:
public class ApiResponse<T> {
private T data;
private String message;
private String trace;
// getters/setters/no-args ctor
}Generic types are tricky to deserialise (Java erases type parameters); use Jackson's TypeRef:
import io.restassured.common.mapper.TypeRef;
ApiResponse<User> wrapper = given()
.when()
.get("/users/1")
.then()
.extract()
.as(new TypeRef<ApiResponse<User>>() {});
User user = wrapper.getData();
Assert.assertEquals(user.getName(), "Alice");Option B — extract the inner object directly:
User user = given()
.when()
.get("/users/1")
.then()
.extract()
.jsonPath()
.getObject("data", User.class);getObject(path, Class) is shorter and skips the wrapper. Use it when the wrapper itself doesn't carry assertions you care about. Use the typed wrapper when you do — for example, when one test asserts both wrapper.getData().getName() and wrapper.getMessage().
Deserialisation visualised
Step 1 of 5
Server responds
Content-Type: application/json with the JSON body. Rest Assured captures it on the .then() chain.
The whole pipeline is invisible at the call site — extract().as(User.class) is one method call. The investment is the POJO, paid once, recouped on every test.
Asserting on deserialised objects
The shape of an assertion changes — from JsonPath expressions to typed accessors:
@Test
public void getUserOneFullValidation() {
User user = given()
.when()
.get("/users/1")
.then()
.statusCode(200)
.extract().as(User.class);
// Direct field assertions
Assert.assertEquals(user.getId(), 1);
Assert.assertEquals(user.getName(), "Alice");
Assert.assertNotNull(user.getEmail());
Assert.assertTrue(user.getEmail().contains("@"));
Assert.assertNotNull(user.getAddress());
Assert.assertEquals(user.getAddress().getCity(), "London");
Assert.assertTrue(user.getRole().matches("admin|tester|viewer"));
}Compare to the equivalent JsonPath chain — you save no LOC, but you gain step-through debugging, autocomplete for every field, and the ability to pass user to a helper method (assertIsValidUser(user)) that runs in any test.
Mixing extracted POJOs with the assertion chain
The two styles aren't mutually exclusive. Run quick body assertions in then(), then extract for deeper work:
User user = given()
.when()
.get("/users/1")
.then()
.statusCode(200)
.body("id", equalTo(1))
.body("email", containsString("@"))
.body("address.city", notNullValue())
.extract().as(User.class);
// Now do typed assertions for the parts JsonPath would make ugly
Assert.assertTrue(user.getOrders().stream()
.allMatch(o -> o.getTotal() > 0 && o.getStatus() != null));A common pattern: the cheap structural checks happen in then(); the business-logic checks (a totals invariant, a referential integrity rule) happen on the extracted object. Mix and match by clarity.
⚠️ Common mistakes
- Forgetting
@JsonIgnoreProperties(ignoreUnknown = true). The first time the API team adds a field, every response POJO without this annotation breaks. Add it to every model class as a default. The contract test (JSON Schema) is the place to fail on new fields, not the deserialiser. - Missing the no-args constructor. Jackson's failure message (
Cannot construct instance of...) is recognisable once you've seen it but slow to debug otherwise. Addpublic User() {}to every model — even when you also have an all-args constructor for tests to use. - Using
Map<String, Object>for everything. It's the lazy escape hatch — and you lose every benefit a POJO gives. If you find yourself writing(String) ((Map) response.get("address")).get("city"), build the POJO. The 30 seconds of typing pays back forever.
🎯 Practice task
Build a real deserialisation pipeline against JSONPlaceholder. 30 minutes.
- Build a
UserPOJO that maps to JSONPlaceholder's/users/1shape:id, name, username, email, phone, websiteplus nestedaddress(withstreet, suite, city, zipcode, geo (lat, lng)) andcompany(withname, catchPhrase, bs). Add@JsonIgnoreProperties(ignoreUnknown = true). - Write
getUserOneAsObject()— extractUserand assert: id == 1, email ends with@april.biz,address.cityis"Gwenborough". - Nested geo. Assert
user.getAddress().getGeo().getLat()is non-null. (JSONPlaceholder returns lat/lng as Strings — note the type.) - List of users. GET
/users, deserialise toList<User>viagetList("", User.class). Assert size is 10 and that one user's name is"Leanne Graham". - Stream-based assertions. From the list, assert every user has a non-empty email, and exactly one user has the username
"Bret". - Wrapper response. GET REQRES's
/api/users/2. The body has the shape{"data": {...}, "support": {...}}. Build anApiResponse<UserData>POJO and deserialise viaTypeRef. Assert on both the data fields and the support message. - Robustness check. Add a new field to your User class that the API doesn't return (e.g.,
private String hometown). Re-run the test. Confirmuser.getHometown()is null and the test still passes — this is whatignoreUnknownand Jackson's setter convention buy you. - Stretch: add a generic helper
<T> T fetchOne(String path, Class<T> type)that does the GET + extract pipeline and returns the typed object. Use it from three different test methods. Note how short the call sites become.
Next lesson: separating request models from response models, and why a single User class for everything is usually the wrong default.