Serialising Java Objects to JSON with Jackson

9 min read

The previous chapters built request bodies as Strings or Maps and asserted on responses field by field. That works, but it asks the test author to keep two parallel realities in their head — the JSON shape on the wire, and the Java code expressing it. Jackson's serialisation erases that gap: hand Rest Assured a typed Java object, and Jackson turns it into JSON automatically. Field renames become compile errors. Autocomplete suggests the right names. The shape lives in one place — the POJO. The Core Java for QA lesson on classes and Jackson set up the language; this lesson is how that translates into the Rest Assured request chain.

A POJO and a request

The simplest serialisation example. Define a class with the fields the API expects:

package com.mycompany.apitests.models;
 
public class User {
    private String name;
    private String email;
    private String role;
 
    public User(String name, String email, String role) {
        this.name = name;
        this.email = email;
        this.role = role;
    }
 
    // getters and setters
    public String getName()         { return name; }
    public void setName(String n)   { this.name = n; }
    public String getEmail()        { return email; }
    public void setEmail(String e)  { this.email = e; }
    public String getRole()         { return role; }
    public void setRole(String r)   { this.role = r; }
}

Pass an instance to body():

@Test
public void createUserFromPojo() {
    User user = new User("Alice", "alice@test.com", "admin");
 
    given()
        .contentType(ContentType.JSON)
        .body(user)
    .when()
        .post("/users")
    .then()
        .statusCode(201)
        .body("name", equalTo("Alice"));
}

Rest Assured detects Jackson on the classpath (the Chapter 1 pom.xml already pulled it in transitively) and uses it to serialise the object. The wire payload comes out as:

{ "name": "Alice", "email": "alice@test.com", "role": "admin" }

No string-building, no escape characters, no chance of a misspelled key — the field name is the field name, enforced by the compiler.

Why Jackson uses getters, not fields

Jackson reads the object's getters by default, not the private fields directly. getName() produces the JSON key name (the Java bean convention strips get and lowercases the first letter). This is why your POJOs need getters even when they look redundant — without them, Jackson sees an empty object and serialises {}. The same convention applies in reverse for deserialisation, which the next lesson covers in detail.

Controlling the JSON output

Real APIs rarely accept a one-to-one mapping between Java and JSON conventions. Jackson's annotations bridge the gap.

@JsonProperty — rename a field on the wire:

import com.fasterxml.jackson.annotation.JsonProperty;
 
public class User {
    @JsonProperty("full_name")    // JSON: "full_name"; Java: name
    private String name;
 
    @JsonProperty("email_address")
    private String email;
    // getters/setters omitted
}

The Java code uses user.getName(); the wire payload uses "full_name": "...". Common when the API team uses snake_case but the team prefers Java's camelCase, or when the API has its own legacy naming.

@JsonIgnore — keep a field out of the JSON:

public class User {
    private String name;
 
    @JsonIgnore
    private String internalDebugId;   // never sent to the API
}

Useful for fields the test code carries for its own bookkeeping (audit IDs, internal flags) that the API has no opinion about.

@JsonInclude(NON_NULL) — drop nulls:

import com.fasterxml.jackson.annotation.JsonInclude;
 
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CreateUserRequest {
    private String name;
    private String email;
    private String role;          // optional
    private String department;    // optional
}

Without it, an unset department serialises as "department": null, which a strict API may reject. With NON_NULL, missing fields are silently dropped — which is usually what partial update and optional field APIs want.

@JsonFormat — control date formatting:

import com.fasterxml.jackson.annotation.JsonFormat;
import java.util.Date;
 
public class Event {
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date eventDate;       // → "2024-01-15"
 
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
    private Date createdAt;       // → "2024-01-15T10:00:00Z"
}

Different APIs use different date formats. Pin yours per field; don't rely on Jackson's default (which serialises Date as a Unix epoch).

Nested POJOs serialise transparently

Jackson recurses into fields whose types are also POJOs:

public class Address {
    private String street;
    private String city;
    private String postcode;
    // getters/setters
}
 
public class User {
    private String name;
    private String email;
    private Address address;     // nested
    // getters/setters
}
 
User user = new User("Alice", "alice@test.com",
    new Address("221B Baker St", "London", "NW1 6XE"));

Serialises to:

{
  "name": "Alice",
  "email": "alice@test.com",
  "address": {
    "street": "221B Baker St",
    "city":   "London",
    "postcode": "NW1 6XE"
  }
}

The same goes for List<T>, Map<String, T>, and any other Jackson-aware type. The shape of the POJO graph mirrors the shape of the JSON.

Serialisation visualised

Step 1 of 5

Build the POJO

new User("Alice", "alice@test.com", "admin") — a normal typed Java object built in the test.

The compile-time wins are real and constant: rename email to emailAddress once on the POJO, every test using it updates automatically. Try the same with hand-written JSON strings and you're searching the codebase for "email".

Serialising a list of POJOs

For endpoints that take an array body — a bulk create, a batch update — pass a List:

List<User> bulk = List.of(
    new User("Alice", "alice@test.com", "admin"),
    new User("Bob",   "bob@test.com",   "viewer"),
    new User("Carol", "carol@test.com", "tester")
);
 
given()
    .contentType(ContentType.JSON)
    .body(bulk)
.when()
    .post("/users/bulk")
.then()
    .statusCode(201);

Jackson serialises the list as a JSON array of objects. Same for Maps (serialised as JSON objects), Set (serialised as JSON arrays — order not preserved), and Optional (Optional.empty()null).

Why this beats Map and String

Three concrete wins, repeated daily:

  • Refactor safety. user.setEmial(...) is a compile error. body.put("emial", ...) is a runtime 400. Multiply across 50 tests.
  • Autocomplete. Typing user. in the IDE lists every field with its type. Hand-written JSON has no support beyond a JSON-aware syntax checker.
  • Reuse. The same User POJO doubles as the response model — extract().as(User.class) gives you a typed object back. The next lesson is about that direction.

The trade-off is the upfront class definition. For a body used in three or more tests, the trade is overwhelmingly worth it; for a one-off, a Map still has its place.

⚠️ Common mistakes

  • Forgetting getters. Jackson serialises via the bean property convention — get-prefixed methods. A POJO with private fields and no getters serialises as {}. Either generate getters with the IDE, use Lombok (Lesson 4), or use Jackson's @JsonAutoDetect(fieldVisibility=ANY) to opt in to direct field access (less common, more invasive).
  • Forgetting a no-args constructor. Serialisation alone doesn't need it, but the response deserialisation in the next lesson does. Add public User() {} from day one — Jackson can't construct a User without it.
  • Mixing snake_case and camelCase silently. A POJO with firstName against an API that expects first_name produces a wire payload the API rejects with a confusing 400. Either annotate every field with @JsonProperty or set Jackson's PropertyNamingStrategy.SNAKE_CASE globally — but pick one and stick to it.

🎯 Practice task

Build a User POJO and exercise serialisation against REQRES. 25–30 minutes.

  1. Create src/test/java/com/mycompany/apitests/models/User.java with three fields (name, job, optional id), a no-args constructor, an all-args constructor, and getters/setters. Compile.
  2. Write createUserFromPojo() — POST /api/users with a User instance, assert 201 and that the response includes the name you sent.
  3. @JsonProperty. Rename the Java field name to fullName and add @JsonProperty("name"). Re-run — the test should still pass because the wire format hasn't changed.
  4. @JsonIgnore. Add a field internalNotes to your User class. Annotate with @JsonIgnore. Set it on the instance, POST, and confirm the request body (via .log().body()) does not include internalNotes.
  5. @JsonInclude(NON_NULL). Add a nullable field phone. POST a User without setting phone. Without the annotation, the body contains "phone": null; add the annotation and confirm the field disappears from the wire.
  6. Nested object. Define an Address class. Add a field Address address to User. POST a User with a populated Address and confirm the JSON shape is { ..., "address": { ... } }.
  7. List body. POST a List<User> to /api/users (REQRES happily echoes whatever shape you send). Use .log().body() to confirm the wire payload is a JSON array.
  8. Stretch: add a field Date createdAt and @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss'Z'", timezone="UTC"). POST and confirm the wire payload uses ISO 8601, not the Unix epoch fallback.

Next lesson: the other direction — taking the JSON the API sends back and turning it into a typed Java object you can assert on with normal getters.

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