Working with JSON in Java — Jackson and Gson Basics

9 min read

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(), then setName(...) etc. via reflection.
  • Field names match JSON keys. A JSON "name" maps to a Java field name. 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 with InvalidDefinitionException. Add a public T() {} (Lombok or a record would also work).
  • Mismatched field name and JSON key. A JSON "user_email" won't populate a Java email field unless you tell it to. Use @JsonProperty("user_email") (Jackson) or @SerializedName("user_email") (Gson) — or rename one of the two.
  • Forgetting TypeReference/TypeToken for generics. mapper.readValue(file, List.class) returns List<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.

  1. In your project, create users.json with an array of three or four user objects (each with name, email, role). Mix admin and member roles.
  2. Add Jackson to your build (pom.xml snippet above) and refresh the project so IntelliJ resolves the dependency.
  3. Create TestUser.java exactly as in the lesson — fields, no-arg constructor, the 3-arg constructor, getters, setters, toString.
  4. Create Demo.java. Use ObjectMapper to read users.json into a List<TestUser> (with TypeReference). Print each user's toString.
  5. Filter to admins only with a Stream pipeline. Write the filtered list to admins.json using writerWithDefaultPrettyPrinter. Open the file and confirm it's properly formatted.
  6. Add @JsonProperty("user_email") to the email field. Update users.json so the key is user_email. Re-run and confirm the deserialisation still works — you've decoupled JSON shape from Java naming.
  7. Try the same round-trip with Gson (parallel demo class). Notice the API surface mirrors Jackson's; the only change worth noticing is the TypeToken form.
  8. Stretch: introduce a deliberate JSON error in users.json (a missing comma). Catch the JsonProcessingException and re-throw as a custom TestDataException from 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.

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