How do I write tests for Java records?

Java records are compact classes designed to hold immutable data. Because records automatically provide a constructor, accessor methods, equals(), hashCode(), and toString(), testing them is usually simpler than testing ordinary classes.

In most cases, you do not need to test Java’s generated record behavior directly. Instead, test:

  • custom validation in the compact constructor
  • custom methods you add to the record
  • behavior that depends on equality or immutability
  • serialization/deserialization if the record is used with JSON or persistence frameworks

Example Record

Suppose you have this Java record:

public record User(String username, String email, int age) {

    public User {
        if (username == null || username.isBlank()) {
            throw new IllegalArgumentException("Username must not be blank");
        }

        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Email must be valid");
        }

        if (age < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return age >= 18;
    }
}

This record has:

  • three components: username, email, and age
  • validation in the compact constructor
  • a custom method named isAdult()

Basic JUnit 5 Test Class

Here is a simple JUnit 5 test class:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class UserTest {

    @Test
    void shouldCreateUserWithValidData() {
        User user = new User("alice", "[email protected]", 25);

        assertEquals("alice", user.username());
        assertEquals("[email protected]", user.email());
        assertEquals(25, user.age());
    }

    @Test
    void shouldReturnTrueWhenUserIsAdult() {
        User user = new User("bob", "[email protected]", 20);

        assertTrue(user.isAdult());
    }

    @Test
    void shouldReturnFalseWhenUserIsNotAdult() {
        User user = new User("charlie", "[email protected]", 15);

        assertFalse(user.isAdult());
    }

    @Test
    void shouldRejectBlankUsername() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("", "[email protected]", 25)
        );

        assertEquals("Username must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectInvalidEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "invalid-email", 25)
        );

        assertEquals("Email must be valid", exception.getMessage());
    }

    @Test
    void shouldRejectNegativeAge() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "[email protected]", -1)
        );

        assertEquals("Age must not be negative", exception.getMessage());
    }
}

Testing Generated Accessor Methods

Record accessors use the component name directly. For example, if your record is:

public record Product(String name, double price) {
}

The accessors are:

product.name();
product.price();

not:

product.getName();
product.getPrice();

A basic test looks like this:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldExposeRecordComponents() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Keyboard", product.name());
        assertEquals(49.99, product.price());
    }
}

However, for plain records with no validation or custom behavior, these tests often provide little value because they only verify Java-generated code.


Testing equals() and hashCode()

Records automatically generate equals() and hashCode() based on all record components.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

class ProductTest {

    @Test
    void shouldCompareRecordsByComponentValues() {
        Product first = new Product("Keyboard", 49.99);
        Product second = new Product("Keyboard", 49.99);
        Product third = new Product("Mouse", 19.99);

        assertEquals(first, second);
        assertEquals(first.hashCode(), second.hashCode());
        assertNotEquals(first, third);
    }
}

Again, you usually do not need this test unless your application depends heavily on equality behavior, such as using records as keys in a Map or elements in a Set.


Testing toString()

Records also generate a readable toString() method:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldGenerateReadableToString() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Product[name=Keyboard, price=49.99]", product.toString());
    }
}

Be careful with this kind of test. It can be brittle because it depends on the exact string format.

Test toString() mainly when:

  • you override it
  • logs or messages depend on its output
  • the string representation is part of your expected behavior

Testing Constructor Validation

Records are commonly used with compact constructors for validation.

public record EmailAddress(String value) {

    public EmailAddress {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Email must not be blank");
        }

        if (!value.contains("@")) {
            throw new IllegalArgumentException("Email must contain @");
        }
    }
}

Test both valid and invalid cases:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class EmailAddressTest {

    @Test
    void shouldCreateEmailAddressWhenValueIsValid() {
        EmailAddress email = new EmailAddress("[email protected]");

        assertEquals("[email protected]", email.value());
    }

    @Test
    void shouldRejectNullEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(null)
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectBlankEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(" ")
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectEmailWithoutAtSign() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress("invalid-email")
        );

        assertEquals("Email must contain @", exception.getMessage());
    }
}

Using Parameterized Tests for Records

Parameterized tests are useful when a record has multiple invalid input values.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class EmailAddressTest {

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "invalid-email", "user.example.com"})
    void shouldRejectInvalidEmailValues(String value) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(value)
        );
    }
}

For more complex data, use @CsvSource:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class UserTest {

    @ParameterizedTest
    @CsvSource({
            "'', [email protected], 25",
            "' ', [email protected], 25",
            "alice, invalid-email, 25",
            "alice, [email protected], -1"
    })
    void shouldRejectInvalidUserData(String username, String email, int age) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new User(username, email, age)
        );
    }
}

Testing Custom Methods in Records

If your record contains business logic, test that logic directly.

public record Money(String currency, int amount) {

    public boolean isPositive() {
        return amount > 0;
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency())) {
            throw new IllegalArgumentException("Currencies must match");
        }

        return new Money(currency, amount + other.amount());
    }
}

Tests:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class MoneyTest {

    @Test
    void shouldReturnTrueForPositiveAmount() {
        Money money = new Money("USD", 100);

        assertTrue(money.isPositive());
    }

    @Test
    void shouldAddMoneyWithSameCurrency() {
        Money first = new Money("USD", 100);
        Money second = new Money("USD", 50);

        Money result = first.add(second);

        assertEquals(new Money("USD", 150), result);
    }

    @Test
    void shouldRejectAddingDifferentCurrencies() {
        Money first = new Money("USD", 100);
        Money second = new Money("EUR", 50);

        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> first.add(second)
        );

        assertEquals("Currencies must match", exception.getMessage());
    }
}

Testing Immutability

Records are shallowly immutable. This means record components cannot be reassigned, but if a component refers to a mutable object, that object can still be changed.

Example:

import java.util.List;

public record Order(List<String> items) {
}

This record is not deeply immutable:

import java.util.ArrayList;
import java.util.List;

Order order = new Order(new ArrayList<>(List.of("Book")));
order.items().add("Pen");

To make it safer, copy the list:

import java.util.List;

public record Order(List<String> items) {

    public Order {
        items = List.copyOf(items);
    }
}

Then test it:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class OrderTest {

    @Test
    void shouldDefensivelyCopyItems() {
        List<String> items = new ArrayList<>();
        items.add("Book");

        Order order = new Order(items);

        items.add("Pen");

        assertEquals(List.of("Book"), order.items());
    }

    @Test
    void shouldExposeUnmodifiableItems() {
        Order order = new Order(new ArrayList<>(List.of("Book")));

        assertThrows(
                UnsupportedOperationException.class,
                () -> order.items().add("Pen")
        );
    }
}

This is a valuable test because it verifies your own defensive-copying behavior, not just Java-generated record behavior.


What Should You Actually Test?

For Java records, focus your tests on behavior you wrote yourself.

Good things to test:

Feature Should You Test It? Why
Accessor methods Usually no Generated by Java
equals() / hashCode() Sometimes Useful if equality is important in your domain
toString() Rarely Usually generated and brittle to assert
Compact constructor validation Yes This is your logic
Custom methods Yes This is your logic
Defensive copying Yes Important for immutability
Serialization/deserialization Yes, if used Important for APIs and persistence

Recommended Testing Style

Use clear test names:

@Test
void shouldRejectNegativeAge() {
    // test body
}

Follow the Arrange-Act-Assert pattern:

@Test
void shouldCreateUserWithValidData() {
    // Arrange
    String username = "alice";
    String email = "[email protected]";
    int age = 25;

    // Act
    User user = new User(username, email, age);

    // Assert
    assertEquals(username, user.username());
    assertEquals(email, user.email());
    assertEquals(age, user.age());
}

Summary

To test Java records:

  1. Do not over-test generated code.
  2. Test constructor validation.
  3. Test custom methods.
  4. Test defensive copying for mutable components.
  5. Test JSON or persistence integration only when records are used that way.
  6. Use JUnit 5 assertions such as assertEquals(), assertTrue(), assertFalse(), and assertThrows().

A plain record like this usually needs no dedicated unit test:

public record Point(int x, int y) {
}

But a record like this should be tested:

public record Age(int value) {

    public Age {
        if (value < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return value >= 18;
    }
}

Because it contains behavior that belongs to your application, not just Java’s generated record features.

How do I use ObjectOutputStream with record?

To use ObjectOutputStream with a Java record, you need to make the record implement the java.io.Serializable interface.

One of the great things about records is that they are designed to be “data carriers,” and Java’s serialization mechanism handles them more robustly and securely than regular classes. Specifically, records are serialized using only their components (the fields defined in the header), and the deserialization process uses the record’s canonical constructor, ensuring that any validation logic you’ve placed there is always executed.

Here is a complete example of how to write a record to a file and read it back:

1. Define the Record

Make sure it implements Serializable.

package org.kodejava.io;

import java.io.Serializable;

/**
 * A simple record representing a Person.
 * Records are implicitly final and their fields are private and final.
 */
public record Person(String name, int age) implements Serializable {
    // Compact constructor for validation
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }
}

2. Serialize and Deserialize

Use ObjectOutputStream to write the object and ObjectInputStream to read it.

package org.kodejava.io;

import java.io.*;

public class RecordSerializationDemo {
    public static void main(String[] args) {
        String filename = "person.ser";
        Person person = new Person("John Doe", 30);

        // 1. Serialize the record
        try (FileOutputStream fos = new FileOutputStream(filename);
             ObjectOutputStream oos = new ObjectOutputStream(fos)) {

            oos.writeObject(person);
            System.out.println("Record saved: " + person);

        } catch (IOException e) {
            e.printStackTrace();
        }

        // 2. Deserialize the record
        try (FileInputStream fis = new FileInputStream(filename);
             ObjectInputStream ois = new ObjectInputStream(fis)) {

            Person savedPerson = (Person) ois.readObject();
            System.out.println("Record loaded: " + savedPerson);

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Key Points to Remember:

  • Immutability: Since records are immutable, serialization is very straightforward.
  • No serialVersionUID Required (mostly): While you can define a serialVersionUID, Java’s serialization for records ignores the field-matching rules that usually require it. The serialization is based strictly on the component names.
  • Security: Records are less susceptible to “deserialization attacks” because they don’t allow the creation of “ghost” objects; they must go through the canonical constructor.
  • Customization: Records do not support writeObject, readObject, readObjectNoData, or writeExternal methods. If you need custom serialization logic, you should use a regular class instead.

How to use record patterns with instanceof in Java 25

Java 25 introduces improvements such as record patterns with instanceof, which allow more concise and expressive type matching and data extraction in one step. Here’s a guide on how to use them:


What are record patterns?

A record pattern enables matching and extracting components of a record class, which is essentially a class with immutable data. Record patterns simplify operations by combining type checking and field extraction syntactically.


Using instanceof with Record Patterns

In Java 25, you can use a record pattern directly with instanceof to both:
1. Match the type of the object.
2. Decompose its contents in a single expression.


Example of Record Patterns with instanceof

record Point(int x, int y) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Point(10, 20);

        // Using instanceof with a record pattern
        if (obj instanceof Point(int x, int y)) {
            System.out.println("Point coordinates: x = " + x + ", y = " + y);
        } else {
            System.out.println("Not a Point object");
        }
    }
}

Explanation

  • obj instanceof Point(int x, int y):
    • Pattern Matching: Verifies if obj is an instance of the Point record.
    • Decomposition: Extracts the x and y fields of the record into variables x and y.

As a result:

  • If obj matches the type, the fields are extracted automatically in the same step.
  • There’s no need to cast obj to Point explicitly or manually call getters.

Nesting Record Patterns

Record patterns can also be nested for more complex records containing other records or collections.

Example: Nested Record Patterns

record Rectangle(Point topLeft, Point bottomRight) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Rectangle(new Point(0, 0), new Point(10, 10));

        if (obj instanceof Rectangle(Point(int x1, int y1), Point(int x2, int y2))) {
            System.out.println("Rectangle corners: (" + x1 + ", " + y1 + ") to (" + x2 + ", " + y2 + ")");
        } else {
            System.out.println("Not a Rectangle object");
        }
    }
}

Explanation

  • Rectangle(Point(int x1, int y1), Point(int x2, int y2)) is a nested pattern:
    • Matches top-level Rectangle.
    • Decomposes its topLeft and bottomRight fields into Point objects.
    • Further extracts x and y coordinates from each Point.

Benefits

  1. Conciseness: Eliminates the need for explicit casting or redundant getter calls.
  2. Readability: Patterns declaratively show what is being matched and extracted.
  3. Flexibility: Works seamlessly with nested structures.

Good-to-Know Details

  1. Exhaustive Matching: Combine switch with record patterns for exhaustive, cleaner matching:
    void printShapeInfo(Object shape) {
       switch (shape) {
           case Point(int x, int y) -> System.out.println("Point: (" + x + ", " + y + ")");
           case Rectangle(Point topLeft, Point bottomRight) -> System.out.println("Rectangle with corners: " +
                   topLeft + " to " + bottomRight);
           default -> System.out.println("Unknown shape");
       }
    }
    
  2. Null Handling: instanceof with patterns doesn’t match null values directly. An explicit null check is still required.

  3. Restrictions: The immutability of records ensures safety and predictability when decomposing data and matching patterns.


Conclusion

The introduction of record patterns in Java 25 significantly enhances pattern matching and makes working with immutable objects far more intuitive and concise. Whether you’re matching simple records or nested structures, this feature saves you from boilerplate code and improves code readability.

How do I use LinkedHashMap for predictable iteration order?

In Java, a LinkedHashMap is a subtype of HashMap that maintains a predictable iteration order. It uses a doubly linked list to store the entries in insertion order (or, optionally, access order). Here’s how you can use LinkedHashMap for predictable iteration order:

1. Maintaining Insertion Order

By default, a LinkedHashMap iterates its entries in the order they were inserted. This is useful when you want to retrieve elements in the same order you added them.

Here’s an example:

package org.kodejava.util;

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        // Creating LinkedHashMap
        Map<String, Integer> map = new LinkedHashMap<>();

        // Adding elements (insertion order)
        map.put("One", 1);
        map.put("Two", 2);
        map.put("Three", 3);
        map.put("Four", 4);

        // Iterating through the map
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " => " + entry.getValue());
        }
    }
}

Output:

One => 1
Two => 2
Three => 3
Four => 4

In this example, the elements are iterated in the same order they were inserted.


2. Maintaining Access Order

You can configure a LinkedHashMap to maintain access order, which means it reorders entries based on the most recent access. To enable access order, you must use the constructor that takes a boolean parameter for accessOrder.

Here’s an example:

package org.kodejava.util;

import java.util.LinkedHashMap;
import java.util.Map;

public class AccessOrderExample {
    public static void main(String[] args) {
        // Creating LinkedHashMap with access-order
        Map<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);

        // Adding elements
        map.put("One", 1);
        map.put("Two", 2);
        map.put("Three", 3);

        // Accessing some elements
        map.get("One");  // Access "One"
        map.get("Three"); // Access "Three"

        // Iterating through the map
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " => " + entry.getValue());
        }
    }
}

Output:

Two => 2
One => 1
Three => 3

In this case:

  • Initially, the insertion order was One, Two, Three.
  • After accessing One and Three, they were moved to the end, making Two the first in the iteration order.

3. Removing the Oldest Entry with Access Order

If needed, you can use a LinkedHashMap in combination with its removeEldestEntry method to automatically remove the oldest entry (e.g., implementing a cache).

Here’s how:

package org.kodejava.util;

import java.util.LinkedHashMap;
import java.util.Map;

public class RemoveEldestExample {
    public static void main(String[] args) {
        // Create LinkedHashMap with override for removeEldestEntry
        LinkedHashMap<String, Integer> map = new LinkedHashMap<>(3, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
                return size() > 3; // Remove oldest if size > 3
            }
        };

        // Adding elements
        map.put("One", 1);
        map.put("Two", 2);
        map.put("Three", 3);
        map.put("Four", 4); // "One" will be removed here

        // Accessing some elements
        map.get("Two");
        map.put("Five", 5); // "Three" will be removed here

        // Iterating through the map
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " => " + entry.getValue());
        }
    }
}

Output:

Four => 4
Two => 2
Five => 5

Explanation:

  1. The map was set to remove the eldest (first) entry when its size exceeds 3.
  2. When "Four" was added, "One" was removed because the size limit was exceeded.
  3. When "Five" was added, "Three" was removed, as it was now the eldest entry after accessing "Two".

Summary of Key Points:

  1. Insertion Order: By default, the iteration order matches the insertion order.
  2. Access Order: Can be enabled using the LinkedHashMap constructor with accessOrder = true.
  3. Custom Behavior: Override the removeEldestEntry method to create a fixed-size cache or similar functionality.

LinkedHashMap is handy when you need consistent iteration order (e.g., for caches, ordering-sensitive collections).

How to create records in Java 17 for immutable data models

In Java 17, you can use the record feature to create immutable data models. Records are a new type of class in Java designed specifically to hold immutable data. Using records simplifies creating classes that are essentially data carriers. Here’s a step-by-step guide on how to create and use records in Java 17:

What is a Record?

A record is a special kind of class in Java introduced in Java 14 (as a preview) and became stable in Java 16+. It:

  • Is designed for immutability
  • Automatically generates boilerplate code like getters, equals(), hashCode(), and toString()

Syntax of a Record

Declaring a record is simple. Here’s the syntax:

public record RecordName(datatype field1, datatype field2, ...) {}

Key Features of Records

  1. Records automatically:
    • Generate getter methods for fields (no need for get prefix – field name itself is used).
    • Override toString(), hashCode(), and equals().
  2. Records are immutable (fields cannot be changed after initialization).

  3. Records can include custom methods.
  4. Records cannot extend other classes (inheritance is not allowed) but can implement interfaces.

An Example: Immutable Data Model with Records

package org.kodejava.basic;

public record Person(String name, int age) {
    // Custom constructor (optional)
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }

    // Example of adding a custom method
    public String greet() {
        return "Hello, my name is " + name + " and I am " + age + " years old.";
    }
}

How to Use Records

You use a record just like any other class:

package org.kodejava.basic;

public class Main {
    public static void main(String[] args) {
        // Create a record instance
        Person person = new Person("John Doe", 30);

        // Access fields using getters
        System.out.println("Name: " + person.name());
        System.out.println("Age: " + person.age());

        // Use a custom method
        System.out.println(person.greet());

        // Immutability tested
        // person.name = "New Name"; // Compilation error because fields are final
    }
}

Output:

Name: John Doe
Age: 30
Hello, my name is John Doe and I am 30 years old.

Advantages of Using Records

  1. Less Boilerplate Code: You don’t need to write getters, setters, constructors, or methods like toString() and hashCode().
  2. Thread-Safety: Records are immutable, making them easy to use in concurrent environments.
  3. Better Readability: The succinct syntax improves code readability.

Restrictions of Records

  1. Records are final — you cannot extend them.
  2. Fields in a record are also final and cannot be changed.
  3. Records themselves cannot be mutable.

When Should You Use Records?

You should use records when:

  • You need a simple data model to hold immutable data.
  • You want to avoid the verbosity of writing boilerplate code for fields and methods (getters, toString(), etc.).

For mutable data, traditional classes or other patterns should be used instead of records.