Writing Tests for Java Serialization with Custom ObjectStreams
Java serialization can look deceptively simple. An object goes into an ObjectOutputStream, bytes travel somewhere, and an ObjectInputStream reconstructs the value. In production code, however, custom stream classes often alter that process through class resolution, object substitution, proxy handling, validation, or special headers.
Those customisations deserve focused tests. A standard round-trip assertion may prove that a basic object survives, while missing the behaviour that matters most: a legacy class name being mapped correctly, a sensitive object being rejected, or a replacement object being written instead of the original.
This matters for backend systems, messaging clients, caches, and asynchronous workflows used by teams from Sydney to Perth. A service may pass local tests yet fail when a different JVM, deployment version, or worker process reads the stream. Clear tests make the binary contract visible and keep refactoring from silently changing it.
The examples below use JUnit 5 and ordinary Java APIs. The same approach works in Groovy specifications, integration tests, and contract suites. For broader Java testing ideas, practical testing notes can provide useful context around maintainability and automation.
Define The Serialization Contract First
Before writing a test, identify what the stream is supposed to guarantee. A useful contract normally covers the object type, the fields that must survive, permitted nulls, collection ordering, compatibility with previous versions, and expected failure modes. It should also state whether object identity matters when the same instance appears more than once in an object graph.
A basic round-trip helper makes this contract easy to exercise:
static <T> T roundTrip(T value) throws IOException, ClassNotFoundException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
output.writeObject(value);
}
try (ObjectInputStream input =
new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
@SuppressWarnings("unchecked")
T restored = (T) input.readObject();
return restored;
}
}
The test should assert meaningful state rather than relying only on equals. If equality is deliberately value-based, use it. Otherwise, assert selected fields, collection contents, timestamps, enum values, and nested objects explicitly. A test named restoresInvoiceStateAfterRoundTrip communicates much more than serialisesObject.
For an Australian payment or logistics platform, this might include a ZoneId, currency amount, and a status transition. A date stored in UTC should remain UTC; a local business date should not unexpectedly shift because a developer ran the test in Melbourne and CI ran it in Brisbane.
Test Custom Class Resolution Explicitly
Custom ObjectInputStream implementations frequently override resolveClass to load classes from a plugin classloader, translate an old class name, or restrict deserialization to an allow-list. These are separate behaviours from ordinary field restoration and should have direct tests.
A test stream can expose the resolved names for verification:
final class TrackingInputStream extends ObjectInputStream {
private final List<String> resolvedNames = new ArrayList<>();
TrackingInputStream(InputStream input) throws IOException {
super(input);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass descriptor)
throws IOException, ClassNotFoundException {
resolvedNames.add(descriptor.getName());
return super.resolveClass(descriptor);
}
}
Write a representative object, read it through TrackingInputStream, and assert that the expected application classes were requested. For a real classloader strategy, inject a loader rather than testing a hard-coded global. The test should prove both successful loading and the exact exception produced for an unknown or forbidden class.
Class-name migration deserves a fixture containing bytes produced by the old version. Do not recreate those bytes during the test with the current class, because that can hide compatibility errors. Store a small binary resource under src/test/resources, document its origin, and verify that the current reader maps it to the new type.
Teams working across Sydney and Adelaide often have rolling deployments, where old workers and new workers coexist for a period. A fixture-based compatibility test reflects that operational reality better than a same-build round trip.
Verify Replacement And Validation Hooks
ObjectOutputStream can replace objects through replaceObject, while ObjectInputStream can replace them through resolveObject. These hooks are useful for canonical values, encrypted wrappers, proxies, and redaction policies. They also introduce a risk: tests may assert the original input and miss that a different object was actually persisted.
Enable replacement explicitly when the custom stream requires it:
final class RedactingOutputStream extends ObjectOutputStream {
RedactingOutputStream(OutputStream output) throws IOException {
super(output);
enableReplaceObject(true);
}
@Override
protected Object replaceObject(Object value) {
if (value instanceof ApiToken) {
return new RedactedToken();
}
return value;
}
}
A strong test asserts the restored type, the retained safe fields, and the absence of the secret. It can also inspect the raw bytes to ensure the original token text is not present. This is especially valuable when serialised data enters logs, queues, or temporary files.
Validation belongs in its own test cases. A readObject method may reject an invalid checksum, while ObjectInputValidation may reject an inconsistent object graph after all objects have been read. Test valid data, invalid data, and the exception boundary. If the public API wraps InvalidObjectException in a domain exception, assert that stable public behaviour rather than an incidental internal stack.
For a service handling customer records in Melbourne or Canberra, a deserialization rule that strips credentials should be treated as a security property. The test should fail loudly if a future refactor restores a privileged field or bypasses the validation hook.
Cover Headers, Streams, And Failure Modes
Custom streams sometimes write a version marker or application header before the normal Java serialization protocol. Test the complete byte sequence, not just the object. A reader should reject a wrong magic value, an unsupported version, and truncated input with predictable exceptions.
When overriding writeStreamHeader or readStreamHeader, include tests for both sides of the boundary:
@Test
void rejectsUnsupportedProtocolVersion() throws Exception {
byte[] bytes = fixture("payload-version-9.bin");
assertThrows(UnsupportedOperationException.class, () -> readWithCustomStream(bytes));
}
Use real byte arrays for malformed input: an empty array, a cut-off header, a valid header with truncated content, and bytes containing an invalid type code. These cases catch bugs that a normal object round trip cannot. They also help ensure that failures happen before expensive allocation or dangerous class loading.
Asynchronous systems need timing-independent tests. Write the bytes to a ByteArrayOutputStream, pass them to the reader only after the writer closes or flushes, and avoid sleeps. If the production implementation reads from a queue, use a deterministic in-memory channel. This is more reliable than reproducing a Perth-to-Sydney network path or depending on variable NBN latency.
Also test stream ownership. If the custom stream must not close a shared connection, wrap the underlying stream with a close-tracking test double. Verify whether flush, close, and repeated reset calls have the intended effect. Resource behaviour is part of the stream contract.
Add Compatibility And Security Cases
Java serialization compatibility is governed by details such as serialVersionUID, field descriptors, optional data, and readObject logic. Maintain tests for an older writer and current reader, and where required, current writer and older reader. A changed field type or accidental generated serialVersionUID should produce a deliberate, reviewable result.
For classes that evolve, test defaults for newly introduced fields and handling of removed fields. A missing field might correctly become null, false, or an empty collection, but the expected default should be explicit. Test enum evolution carefully because unknown enum constants generally fail deserialization rather than falling back harmlessly.
Security tests should verify an allow-list or ObjectInputFilter, not merely successful reading. Include a permitted domain object, a forbidden class, an excessive array length, and a deeply nested graph. Confirm that rejection happens with an expected exception and that no application action occurs before the rejection.
Never use untrusted production payloads casually in a unit test. Keep fixtures synthetic and inspectable. For teams supporting Australian banks, government suppliers, or health platforms, this discipline also makes audit evidence easier to explain: the suite demonstrates what can be read, what is rejected, and which compatibility promises are intentional.
| Behaviour | Useful Test Input | Assertion |
|---|---|---|
| Ordinary round trip | Valid domain object | State and relevant identity are preserved |
| Class mapping | Bytes from an older class name | Current class is loaded successfully |
| Replacement | Object containing a secret or proxy | Safe replacement type and fields are restored |
| Validation | Invalid checksum or graph | Stable domain or stream exception is raised |
| Header handling | Wrong version or truncated header | Input is rejected before object use |
| Compatibility | Fixture from a previous release | Documented defaults and fields are retained |
| Filtering | Forbidden class or oversized graph | Deserialization is blocked |
Make The Suite Fast And Maintainable
Keep pure byte-level tests separate from integration tests that involve files, sockets, queues, or classloader assemblies. Most custom stream behaviour can be tested in memory in milliseconds. A smaller number of integration tests can then verify that the configured production stream is wired correctly.
Name tests around observable behaviour: mapsLegacyDescriptorToCurrentClass, doesNotPersistApiToken, or rejectsUnknownHeaderVersion. Avoid asserting implementation details such as the exact number of calls to resolveClass unless that call count is part of the contract. Internal ordering can change while the serialised result remains correct.
Use parameterised tests for malformed headers, filter limits, and several incompatible versions. Store binary fixtures with a short README describing the Java release, class version, and creation purpose. Review fixture changes like source code; a binary diff can hide a major protocol change.
The same suite can run in Java and Groovy projects, but keep the central assertions readable. In a fast-moving Brisbane startup, a test that explains the protocol rule is more valuable than a clever helper that saves three lines. Run compatibility and security cases in continuous delivery, while leaving large performance payloads for a scheduled job.
Reliable coverage comes from treating serialization as a protocol with explicit promises. Test the normal object graph, custom class loading, replacement and validation hooks, headers, malformed bytes, version changes, and security filters. The reader should remember that a successful round trip is only the starting point: trustworthy custom ObjectStream code is proved by the values it preserves and the inputs it safely refuses.