How to mock Java records and sealed classes in tests
Java records and sealed classes make backend models compact, explicit, and hard to extend. Records package immutable values, while sealed types restrict implementations to a known set. That rigidity is useful in payment, insurance, and telemetry systems, but it defeats the classic technique of creating a mock subclass. Testing these types therefore calls for a deliberate choice between real value objects, instrumented mocks, and mocks of their collaborators.
Most examples combine JUnit 5, Mockito, and AssertJ. Inline mocking can handle final classes, but sealed hierarchies and record value semantics still need care. The setting is familiar in Australia: a Sydney fintech might model payment events as a sealed interface, while a Melbourne consultancy might apply the same pattern to claims. Australian English terms such as “authorised” and “cancelled” can even become tested API fields.
Why records and sealed types resist basic mocking
Records and sealed types complicate test doubles for different reasons. A record class is final, its component state is fixed at construction, and its useful accessors are compiler-generated. A conventional mocking engine that creates subclasses cannot extend that final class. This failure was common with older Mockito configurations and remains relevant to other libraries that rely on runtime proxy generation rather than class instrumentation.
Sealed types add a runtime restriction to the compile-time hierarchy. The JVM checks that direct subclasses are listed in the permits clause, so a generated proxy can be rejected even if the tool can handle final classes. This does not make sealed types untestable. It means the chosen target matters: mocking a permitted implementation may work, while directly mocking a sealed parent can require special support or may fail during proxy creation.
Enabling Mockito for final and sealed types
Mockito 5 uses its inline mock maker by default, so a current mockito-core release can mock a record without changing production visibility. The inline maker instruments the loaded class rather than defining an ordinary child proxy, which is why final records are candidates for mocking. Some interface mocks still require generated implementations, explaining the extra caution needed around sealed parents.
The essential test is small:
var receipt = mock(PaymentReceipt.class);
when(receipt.reference()).thenReturn("pay-4821");
when(receipt.amountCents()).thenReturn(12_500L);
Because the mock’s constructor is not run, component fields contain default values. Each accessor must be stubbed when the test depends on it, and a missed stub can resemble a production defect. This is a mocking artefact rather than record behaviour.
For a project still on Mockito 4, enable inline mocking with the file src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker containing mock-maker-inline. Java 21 pattern matching and modern JVM builds work best with aligned Mockito and Byte Buddy versions. Run one inline-mock test in CI, including the project’s normal JDK and agent flags, because dynamic-agent warnings or sealed-proxy failures should surface locally rather than in a delivery pipeline.
Mocking records without losing value semantics
Given a PaymentReceipt record, a Mockito test can stub its generated accessors:
enum PaymentStatus { PENDING, CAPTURED, CANCELLED }
record PaymentReceipt(
String reference,
long amountCents,
PaymentStatus status
) {}
PaymentReceipt receipt = mock(PaymentReceipt.class);
when(receipt.reference()).thenReturn("pay-4821");
when(receipt.amountCents()).thenReturn(12_500L);
when(receipt.status()).thenReturn(PaymentStatus.CAPTURED);
assertThat(receipt.amountCents()).isEqualTo(12_500L);
This approach is useful for a narrow branch, such as proving that a service translates a receipt into a command. Keep the stubbing close to that assertion. If the test checks serialisation, equality, hashing, or the complete receipt contents, construct the record instead.
A mocked record is still a mock, not a faithful value. Mockito does not let tests replace equals or hashCode, and toString may show mock metadata. A mocked receipt can therefore fail an assertion that two otherwise identical receipts should be equal. Real records are cheap, deterministic, and aligned with production value semantics:
var receipt = new PaymentReceipt(
"pay-4821",
12_500L,
PaymentStatus.CAPTURED
);
A validating factory or builder is preferable when components have invariants, such as non-negative amounts or an explicit currency. Mocking also bypasses the compact constructor entirely, so a test expecting an invalid-argument error must instantiate the real record inside an exception assertion rather than expect mock creation to throw.
Selecting a safe target in a sealed hierarchy
A sealed interface defines the allowed branches but does not guarantee that every mock library can create an implementation. Consider this small event model:
sealed interface PaymentEvent
permits CardPayment, BankTransfer {}
record CardPayment(
String merchantReference,
long amountCents
) implements PaymentEvent {}
record BankTransfer(
String accountReference,
long amountCents
) implements PaymentEvent {}
With the inline mock maker, a permitted record such as CardPayment can be mocked and retains that runtime type. A direct mock of PaymentEvent is less dependable because a generated implementation is not one of the permitted names.
Mock the branch under test, then keep the hierarchy’s intent visible:
CardPayment card = mock(CardPayment.class);
when(card.merchantReference()).thenReturn("merchant-771");
when(card.amountCents()).thenReturn(9_900L);
assertThat(card).isInstanceOf(CardPayment.class);
A Java 21 pattern switch will still see CardPayment, which is valuable for testing exhaustive dispatch. Stubbed accessors return defaults unless specified, and mocking cannot validate a subtype’s constructor invariants. For orchestration logic, another effective design is to map sealed domain events to an ordinary, non-sealed interface, then mock that collaborator. This preserves the sealed domain model without making framework compatibility part of every unit test.
Preserving behaviour across service boundaries
Records often cross boundaries before a test reaches the service. Jackson can serialise and deserialise record components, while a polymorphic API needs explicit registration for each permitted subtype. Test those payloads with real record instances: they expose missing constructors, unexpected nulls, naming mismatches, and subtype metadata that a mocked accessor can conceal. The same rule applies to database mappings and command-line interfaces.
Synthetic data matters in Australian systems handling customer information, and fixtures should follow the Australian Privacy Principles rather than copy production records. For a non-sensitive demo, a Henry VIII reference can guide harmless historical labels, while a factory can generate AUD amounts and GST entries without touching real accounts.
Across asynchronous tests, send a real record through the broker. Once work leaves the test JVM, an in-memory Mockito stub cannot influence it. Teams distributed between Perth and Brisbane also need generous retry windows because their clocks are two hours apart; a timing bug should not masquerade as a flaky mock. Containerised broker and database tests can capture these payload contracts while pure mock tests remain focused on branch logic.
Choosing a practical testing strategy
Different layers call for different substitutes:
| Approach | Best fit | Main limitation |
|---|---|---|
| Real record instance | Value semantics, JSON, collections, equality | Every component must be valid |
| Mocked record | One isolated accessor or branch | Identity and Object methods differ |
| Mocked permitted implementation | A specific sealed branch | Inline support and runtime type matter |
| Direct mock of sealed parent | Rare framework-specific cases | Permitted-subclass rules may reject it |
| Mocked ordinary collaborator | Orchestration and side effects | Less domain fidelity |
A balanced suite constructs real records at API, event, and persistence boundaries, then uses mocks only for slow or stateful collaborators such as payment gateways. If a sealed branch genuinely must be isolated, mock a permitted implementation and assert its concrete runtime type. Keep mock configuration local to each test so a stubbed record cannot quietly spread through unrelated assertions.
The practical takeaway is simple: prefer real record values, mock only the behaviour under examination, and treat a sealed hierarchy as a contract rather than a collection of substitutable classes. That combination preserves Java’s type guarantees while keeping tests fast, readable, and faithful to production data.