JSON is the universal language of test data. Fixtures are JSON. API responses are JSON. Test reports are JSON. Configuration is increasingly JSON. JavaScript reads and writes it natively (JSON.parse, JSON.stringify); Java does not — you need a library. The two everyone uses are Jackson (the de-facto standard, comes bundled with Rest Assured and Spring) and Gson (Google's, slightly simpler API, smaller dependency). This lesson covers both, with the same example, so you can read either when you meet it in real code.
Adding the dependency
JSON libraries don't ship with the JDK. Add them through your build tool. With Maven, drop one of these into pom.xml inside <dependencies>:
<!-- Jackson — most common -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
<!-- OR Gson — simpler API -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>With Gradle:
implementation "com.fasterxml.jackson.core:jackson-databind:2.17.0"
implementation "com.google.code.gson:gson:2.11.0"We'll cover Maven properly in chapter 8 of this course; for now, IntelliJ's Project Structure → Modules → Dependencies will accept either via "Library from Maven."
Your data class
Both libraries map JSON to Java by walking your class's fields and getters/setters. Define the class first:
public class TestUser {
private String name;
private String email;
private String role;
public TestUser() {} // no-arg constructor required for deserialisation
public TestUser(String name, String email, String role) {
this.name = name;
this.email = email;
this.role = role;
}
public String getName() { return name; }
public String getEmail() { return email; }
public String getRole() { return role; }
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
public void setRole(String role) { this.role = role; }
@Override
public String toString() {
return "TestUser{" + name + ", " + email + ", " + role + "}";
}
}The conventions both libraries expect:
- A no-argument constructor. The library calls
new TestUser(), thensetName(...)etc. via reflection. - Field names match JSON keys. A JSON
"name"maps to a Java fieldname. We'll see how to map differently in a moment. - Getters and setters for each field (or public fields directly, but encapsulation from chapter 4.3 wins long-term).
A sample user.json:
{
"name": "Alice",
"email": "alice@test.com",
"role": "admin"
}Jackson — read JSON into a Java object
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.IOException;
public class JacksonRead {
public static void main(String[] args) throws IOException {
ObjectMapper mapper = new ObjectMapper();
TestUser user = mapper.readValue(new File("user.json"), TestUser.class);
System.out.println(user);
System.out.println("role = " + user.getRole());
}
}Output:
TestUser{Alice, alice@test.com, admin}
role = admin
ObjectMapper is Jackson's main entry point. readValue accepts a File, a String, an InputStream, or a URL and a target type; it walks the JSON, pulls each key out, and calls the matching setter on a fresh instance. If a key in the JSON has no setter on the class, Jackson by default ignores it (you can configure it to throw — see FAIL_ON_UNKNOWN_PROPERTIES).
Jackson — write Java to JSON
The other direction is just as short:
TestUser user = new TestUser("Alice", "alice@test.com", "admin");
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);
System.out.println(json);Output:
{"name":"Alice","email":"alice@test.com","role":"admin"}
For pretty printing, ask the mapper for a writer with the default pretty printer:
String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(user);{
"name" : "Alice",
"email" : "alice@test.com",
"role" : "admin"
}
To write straight to a file:
mapper.writeValue(new File("output.json"), user);writeValue overwrites the target file with the serialised JSON. Combined with try-with-resources for paths, it's the round-trip you'll use for fixtures and snapshot reports.
Lists of objects — TypeReference
A JSON array of users:
[
{ "name": "Alice", "email": "a@x.com", "role": "admin" },
{ "name": "Bob", "email": "b@x.com", "role": "member" }
]You can't just write mapper.readValue(file, List<TestUser>.class) — Java's generics are erased at runtime, so the library can't see "list of what." Jackson's solution is TypeReference:
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.List;
List<TestUser> users = mapper.readValue(
new File("users.json"),
new TypeReference<List<TestUser>>() {}
);
for (TestUser u : users) {
System.out.println(u);
}Output:
TestUser{Alice, a@x.com, admin}
TestUser{Bob, b@x.com, member}
The slightly odd-looking new TypeReference<...>() {} is an anonymous subclass that captures the generic type at compile time so Jackson can read it back. You'll see this pattern any time you deserialise into a generic container.
Gson — the simpler alternative
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.FileReader;
import java.io.IOException;
public class GsonDemo {
public static void main(String[] args) throws IOException {
Gson gson = new Gson();
try (FileReader r = new FileReader("user.json")) {
TestUser user = gson.fromJson(r, TestUser.class);
System.out.println(user);
}
TestUser made = new TestUser("Carol", "carol@test.com", "guest");
String pretty = new GsonBuilder().setPrettyPrinting().create().toJson(made);
System.out.println(pretty);
}
}Output:
TestUser{Alice, alice@test.com, admin}
{
"name": "Carol",
"email": "carol@test.com",
"role": "guest"
}
The API mirrors Jackson's: gson.fromJson(...) for read, gson.toJson(...) for write. Configuration is via a fluent GsonBuilder. For lists, Gson uses TypeToken instead of TypeReference:
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
Type listType = new TypeToken<List<TestUser>>(){}.getType();
List<TestUser> users = gson.fromJson(new FileReader("users.json"), listType);Choose Jackson when you're working with Rest Assured (it's already on the classpath), Spring, or any large enterprise codebase — Jackson is the default. Choose Gson when you want a smaller dependency or simpler API for a stand-alone tool. They're functionally equivalent for everyday QA work.
Mapping JSON keys to differently-named fields
Sometimes the JSON uses snake_case and your Java uses camelCase. Or the JSON key has spaces or unusual characters. Both libraries support an annotation:
Jackson:
import com.fasterxml.jackson.annotation.JsonProperty;
public class TestUser {
@JsonProperty("user_email")
private String email;
// ...
}Gson:
import com.google.gson.annotations.SerializedName;
public class TestUser {
@SerializedName("user_email")
private String email;
// ...
}Both annotations work in both directions — read and write — so the mapping stays consistent. This is especially useful when you don't control the API and its field names don't match Java conventions.
A real QA round-trip
Read a fixture, filter, and write the survivors:
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.IOException;
import java.util.List;
public class FilterAdmins {
public static void main(String[] args) throws IOException {
ObjectMapper mapper = new ObjectMapper();
List<TestUser> all = mapper.readValue(
new File("users.json"),
new TypeReference<List<TestUser>>() {}
);
List<TestUser> admins = all.stream()
.filter(u -> "admin".equals(u.getRole()))
.toList();
mapper.writerWithDefaultPrettyPrinter()
.writeValue(new File("admins.json"), admins);
System.out.println("Filtered " + all.size() + " users → " + admins.size() + " admins");
}
}Read JSON → in-memory list → filter with Streams → write JSON. That round-trip is the daily life of a test data layer.
Two libraries, same shape
Jackson vs Gson — same shape, different APIs
Jackson
Entry point: ObjectMapper
Read: mapper.readValue(file, Type.class)
Write: mapper.writeValueAsString(obj) / writeValue(file, obj)
Generic types: new TypeReference<List<T>>() {}
Annotation: @JsonProperty("json_key")
Default in: Rest Assured, Spring, most enterprise Java
Gson
Entry point: Gson (built via new Gson() or GsonBuilder)
Read: gson.fromJson(reader, Type.class)
Write: gson.toJson(obj)
Generic types: new TypeToken<List<T>>() {}.getType()
Annotation: @SerializedName("json_key")
Lighter dependency, simpler API; common in Android and small tools
The shape is identical: an entry-point object, a read method, a write method, a TypeReference/TypeToken trick for generics, an annotation for renaming fields. Switching between the two is mostly find-and-replace.
Tip: qa.codes/utilities/json-formatter lets you paste raw API responses to confirm they're valid before writing the matching Java class.
⚠️ Common mistakes
- No no-arg constructor on your data class. Both libraries call
new T()and then setters. If your class only has a constructor with arguments, deserialisation fails withInvalidDefinitionException. Add apublic T() {}(Lombok or arecordwould also work). - Mismatched field name and JSON key. A JSON
"user_email"won't populate a Javaemailfield unless you tell it to. Use@JsonProperty("user_email")(Jackson) or@SerializedName("user_email")(Gson) — or rename one of the two. - Forgetting
TypeReference/TypeTokenfor generics.mapper.readValue(file, List.class)returnsList<Object>(or worse, fails). For typed lists, the anonymous-class trick is mandatory:new TypeReference<List<TestUser>>() {}.
🎯 Practice task
Read, filter, and write JSON fixtures. 30 minutes.
- In your project, create
users.jsonwith an array of three or four user objects (each withname,email,role). Mix admin and member roles. - Add Jackson to your build (
pom.xmlsnippet above) and refresh the project so IntelliJ resolves the dependency. - Create
TestUser.javaexactly as in the lesson — fields, no-arg constructor, the 3-arg constructor, getters, setters,toString. - Create
Demo.java. UseObjectMapperto readusers.jsoninto aList<TestUser>(withTypeReference). Print each user'stoString. - Filter to admins only with a Stream pipeline. Write the filtered list to
admins.jsonusingwriterWithDefaultPrettyPrinter. Open the file and confirm it's properly formatted. - Add
@JsonProperty("user_email")to the email field. Updateusers.jsonso the key isuser_email. Re-run and confirm the deserialisation still works — you've decoupled JSON shape from Java naming. - Try the same round-trip with Gson (parallel demo class). Notice the API surface mirrors Jackson's; the only change worth noticing is the
TypeTokenform. - Stretch: introduce a deliberate JSON error in
users.json(a missing comma). Catch theJsonProcessingExceptionand re-throw as a customTestDataExceptionfrom lesson 2 with the offending file path. The combination — JSON, exceptions, custom errors — is what real fixture loaders look like.
That closes Chapter 7. You can now read fixtures, write reports, surface domain-specific errors, and round-trip JSON. The next chapter (Strings, Regex, Java 8+ features) sharpens the data-processing skills you'll layer on top of all of this.