Testing Java Records and Sealed Classes with JUnit 5

Java records and sealed classes bring stronger modelling tools to backend applications. Records make immutable data carriers concise, while sealed hierarchies define exactly which types may exist. These features reduce boilerplate, but they also change what deserves attention in a test suite.

JUnit 5 is well suited to this style of testing. Its parameterised tests, nested test classes, dynamic tests and expressive assertions allow you to verify generated behaviour as well as business rules. The key is to avoid testing Java’s implementation for its own sake and focus on the contracts your application relies on.

For Australian teams building REST APIs, payment services or event-driven systems, those contracts can include JSON field names, validation rules, permitted event types and time-zone handling. A test that passes on a developer laptop in Sydney should still express the same behaviour in a CI environment running in Melbourne, Brisbane or another region.

Java feature Useful test focus JUnit 5 technique
Record components Values, accessors and validation Assertions and parameterised tests
Canonical constructor Invalid and normal input assertThrows and regular tests
equals and hashCode Value equality and collection behaviour assertEquals, assertNotEquals
Sealed interface Supported implementations Direct tests and exhaustive dispatch
Sealed class hierarchy Business rules by subtype @Nested, parameterised tests
Record patterns and switches Pattern matching outcomes Branch-focused unit tests

Start With the Contract

A record should be tested as a value object. Consider a request model used by a REST endpoint:

public record CustomerAddress(
        String street,
        String suburb,
        String state,
        String postcode) {

    public CustomerAddress {
        if (street == null || street.isBlank()) {
            throw new IllegalArgumentException("street is required");
        }
        if (postcode == null || !postcode.matches("\\d{4}")) {
            throw new IllegalArgumentException("postcode must have four digits");
        }
    }
}

The first tests should establish that valid data is stored and exposed through the generated accessors. They should also verify that the canonical constructor rejects invalid values. This is more valuable than asserting that the compiler generated a method called postcode(), because the application contract is that a valid Australian postcode must be accepted and an invalid one must be rejected.

class CustomerAddressTest {

    @Test
    void storesValidAddress() {
        var address = new CustomerAddress(
                "1 George Street", "Sydney", "NSW", "2000");

        assertAll(
                () -> assertEquals("1 George Street", address.street()),
                () -> assertEquals("Sydney", address.suburb()),
                () -> assertEquals("NSW", address.state()),
                () -> assertEquals("2000", address.postcode())
        );
    }

    @Test
    void rejectsInvalidPostcode() {
        var error = assertThrows(
                IllegalArgumentException.class,
                () -> new CustomerAddress(
                        "1 George Street", "Sydney", "NSW", "20"));

        assertEquals("postcode must have four digits", error.getMessage());
    }
}

Use assertAll when several independent properties belong to one scenario. If the first assertion fails, JUnit 5 reports the remaining failures as well, which makes model tests easier to diagnose. For larger records, parameterised tests can cover blank strings, nulls and malformed values without duplicating the test method.

Verify Value Semantics

Records automatically provide equals, hashCode and toString, but those generated methods still affect application behaviour. A record used as a cache key, a set member or a message deduplication key must compare according to its components. Test two separately created instances with the same values, then test a meaningful difference.

@Test
void recordsWithTheSameComponentsAreEqual() {
    var first = new CustomerAddress("1 King Street", "Melbourne", "VIC", "3000");
    var second = new CustomerAddress("1 King Street", "Melbourne", "VIC", "3000");

    assertEquals(first, second);
    assertEquals(first.hashCode(), second.hashCode());
    assertTrue(new HashSet<>(List.of(first)).contains(second));
}

A separate test should establish that a changed component changes equality. Avoid asserting the entire toString() output unless it is part of a published diagnostic or logging contract. The exact formatting is generated behaviour and can make tests brittle when a component is renamed.

Nested records and reference components need more care. A record containing a list still refers to a mutable list, and a record containing an array uses the array’s standard reference-based equality unless you implement special handling. If immutability is important, test defensive copying or use immutable component types. This distinction is especially relevant for API payloads that pass through several asynchronous processing stages.

Test Canonical Constructors and Validation

A compact canonical constructor is an ideal place for invariants such as range checks, normalisation and cross-field validation. Test each business rule independently. For a money type, this might include a non-negative amount and a supported currency. For an Australian address, it could include a state code restricted to NSW, VIC, QLD, WA, SA, TAS, NT or ACT.

@ParameterizedTest
@NullSource
@ValueSource(strings = {"", " ", "200"})
void rejectsInvalidPostcodes(String postcode) {
    assertThrows(IllegalArgumentException.class, () ->
            new CustomerAddress("10 Queen Street", "Brisbane", "QLD", postcode));
}

Keep constructor tests independent from JSON deserialisation, database mapping and controller tests. Those integrations deserve their own tests because frameworks may call constructors differently or require reflection. A focused unit test tells you whether the record itself enforces its invariant; an integration test tells you whether the framework preserves that invariant at a system boundary.

Normalisation also needs an explicit decision. If a constructor trims a suburb or converts a currency code to uppercase, assert the normalised result. If the application must retain the original user input for audit purposes, do not silently normalise it in the record. In regulated Australian sectors such as banking and insurance, preserving the difference between submitted and canonical data can be important during incident review.

When tests are published with a technology blog or shared inside a company, remember that surrounding notices and disclosures may apply; keep those separate from the executable examples by linking to the site’s policy notices where appropriate.

Exercise Sealed Type Hierarchies

A sealed type defines a closed set of direct subclasses. That makes a useful domain contract for commands, payment outcomes or domain events:

public sealed interface PaymentResult
        permits Approved, Declined, Pending { }

public record Approved(String reference) implements PaymentResult { }

public record Declined(String reason) implements PaymentResult { }

public record Pending(String trackingId) implements PaymentResult { }

Test each permitted subtype according to its own behaviour. Approved may require a non-blank reference, while Declined may require a customer-facing reason. The sealed interface itself usually has little behaviour to test unless it declares methods or provides shared logic through a sealed abstract class.

The most important test often sits in the dispatcher or service that consumes the hierarchy:

String message(PaymentResult result) {
    return switch (result) {
        case Approved approved -> "Payment accepted: " + approved.reference();
        case Declined declined -> "Payment declined: " + declined.reason();
        case Pending pending -> "Payment is being processed";
    };
}

Java’s exhaustive switch checking protects the source code when a new permitted subtype is added, but tests still need to verify the meaning of each branch. A parameterised test can pair each result with its expected message. This gives a clear failure when a developer changes wording, mapping or downstream behaviour.

Sealed classes also work well with @Nested test classes. Put shared setup for a payment workflow in the outer class, then group tests for approved, declined and pending outcomes. The structure mirrors the domain model and makes it clear which rules belong to a subtype rather than to the common interface.

Combine Records With API and Async Tests

Records are frequently used as request and response DTOs. Unit tests should cover their validation and value semantics, while API tests verify serialisation, deserialisation and error responses. For example, an HTTP test can prove that a JSON property called postcode becomes the correct record component and that invalid input produces the expected status code.

Do not assume that Java component names always match the wire format. Jackson configuration, annotations and naming strategies can alter the result. Test optional fields, unknown properties, null handling and nested sealed values at the boundary. A record that is correct in isolation may still fail when a client sends a discriminator such as "type": "declined".

Asynchronous systems add ordering and timing concerns. Suppose a sealed event hierarchy contains OrderPlaced, PaymentCaptured and OrderCancelled, each represented by a record. Test that consumers handle each permitted event, that duplicate records are treated according to the idempotency contract, and that an event with an invalid payload is rejected or sent to a dead-letter path.

Time-sensitive tests should use an injected Clock, especially when deployments span Australian time zones. A record containing an Instant should be compared using a fixed clock rather than the machine’s current time. If the business rule is based on local dates, explicitly test daylight-saving transitions in Sydney or Melbourne and compare that behaviour with Brisbane, which does not observe daylight saving. This prevents a passing UTC-based test from hiding a production defect.

Keep Tests Stable as Models Evolve

Records and sealed hierarchies make domain changes visible at compile time, which is a major advantage. When a new permitted subtype is introduced, an exhaustive switch may fail to compile until the new case is handled. Treat that compiler error as a useful inventory of affected behaviour, then add focused tests for the new subtype before changing broad integration suites.

Avoid reflection-heavy tests that inspect every record component or every permitted subclass unless you are writing a reusable framework or architectural rule. Such tests can duplicate the compiler and create noise when a harmless field is added. Prefer tests that describe observable outcomes: validation, mapping, equality, dispatch and persistence.

A small architecture test can still be worthwhile when a project must enforce boundaries. For example, ArchUnit can check that records used as API contracts do not depend on infrastructure packages, while ordinary JUnit 5 tests verify runtime behaviour. This division keeps structural rules separate from domain examples.

For a team working across Sydney and Perth, consistent Java versions matter as much as consistent assertions. Run the same JDK release locally and in continuous integration, compile with the intended --release level, and include mutation testing or coverage checks where they provide useful evidence. Begin by adding one JUnit 5 test for a validated record and one parameterised dispatch test for every currently permitted sealed subtype.