Testing Java RMI with an Embedded RMI Registry
Java Remote Method Invocation (RMI) lets a client invoke a method on an object living in another JVM, even when that object is part of the same application. A registry provides the initial lookup, returning a remote stub that carries the network endpoint and handles argument marshalling. Testing this boundary requires more than calling a service implementation directly.
An embedded RMI registry starts inside the test JVM, giving tests a realistic lookup and remote invocation path without installing a separate rmiregistry process. It can expose port conflicts, class-loading faults, serialisation errors, and hostname mistakes that ordinary unit tests miss. The key is to control the registry lifecycle while keeping each test independent.
Why the registry belongs in the test boundary
The registry is more than a map of names to objects. It is the entry point through which a client obtains a serialisable stub before any business method runs. A test that bypasses it and injects the implementation directly may still pass while a real client receives an unusable endpoint or encounters an incompatible DTO.
An embedded registry closes that gap because LocateRegistry.createRegistry creates the registry in the current JVM. The test can then perform a genuine lookup and invoke the returned stub over JRMP, the RMI wire protocol carried by TCP.
This approach does not reproduce every difference between separate processes, but it exercises the registry, proxy creation, serialisation, transport, and exception unmarshalling without external infrastructure. That makes it a practical default for most service-level integration suites.
Building a deterministic registry fixture
JUnit 5 works well here through a custom extension or an AutoCloseable fixture. Each test receives a new registry, service implementation, and unique binding name. Hard-coded port 1099 should be avoided because parallel builds or another local application may already own it.
A fixture can reserve an available port, start the registry, export the service, and expose a client created through a registry lookup:
final class RmiFixture implements AutoCloseable {
private final int registryPort = findFreePort();
private final Registry registry =
LocateRegistry.createRegistry(registryPort);
private final GreetingService service = new GreetingServiceImpl();
RmiFixture() throws Exception {
registry.bind("greeting-service", service);
}
GreetingService client() throws Exception {
Registry remote =
LocateRegistry.getRegistry("127.0.0.1", registryPort);
return (GreetingService) remote.lookup("greeting-service");
}
@Override
public void close() throws Exception {
try {
registry.unbind("greeting-service");
} finally {
UnicastRemoteObject.unexportObject(service, true);
UnicastRemoteObject.unexportObject(registry, true);
}
}
private static int findFreePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
The service implementation should extend UnicastRemoteObject and use super(0), allowing RMI to allocate an anonymous service port. The client only needs the registry port because the returned stub contains the service endpoint. The port-probing approach has a small race between closing the socket and opening the registry; highly parallel suites can use a per-worker port range or serialise fixture creation.
The hostname must be configured before any remote object is exported. Supplying -Djava.rmi.server.hostname=127.0.0.1 to the test JVM is safer than changing the system property during execution, especially when other tests export remote objects concurrently. Teardown should always unbind the name and unexport both objects to prevent leaked threads and stale endpoints.
Wiring services and real remote clients
The client should always obtain its stub through LocateRegistry.getRegistry(...).lookup(...). Passing the GreetingServiceImpl reference into the test proves that the method works, but it bypasses the registry and may bypass the remote call path entirely. A lookup-based client exercises the same discovery sequence used by production code.
Consistent addressing matters as well. If the registry is created on 127.0.0.1, the client should use the same address rather than relying on DNS, IPv6 resolution, or a machine hostname that differs between workstations. Otherwise, a test may pass on one developer’s laptop and fail on a build agent because the stub advertises an unreachable host.
Assertions should also distinguish transport failures from application behaviour. A successful call should return the expected greeting, while a deliberately invalid request should produce the service’s domain-specific error. Logging the lookup and invocation separately helps reveal whether a failure occurred during discovery or execution.
Handling data, exceptions, and class loading
Every value crossing an RMI boundary must be serialisable. Remote interfaces should extend java.rmi.Remote, and DTOs should implement java.io.Serializable with an explicit serialVersionUID. Non-static inner classes are poor choices because they retain a reference to their enclosing instance and often introduce class-loading or serialisation problems.
Application exceptions need the same treatment. If a remote method throws an exception that cannot be serialised, the client receives an UnmarshalException rather than the original failure. Defining a serialisable business exception preserves the cause and makes assertions meaningful. Transport problems can continue to use RemoteException.
The remote contract and DTO classes should live in a shared API artifact rather than in the implementation module. This matters even when the registry and service share a JVM, particularly under class-loader-based application servers. Separate class loaders may otherwise produce a ClassCastException for classes with the same name.
A robust fixture also handles partial startup and teardown failures. If service export succeeds but binding fails, the registry and service must still be released. Assertions about cleanup, thread counts, and open sockets can reveal leaks that appear only after several tests run.
Running the suite across Australian delivery teams
Australian teams in Sydney and Melbourne often maintain Java estates in banking, insurance, logistics, and public administration, where RMI may connect a long-lived operational system to a newer application. An embedded registry gives these teams a fast way to preserve that compatibility boundary without maintaining a shared RMI host on every workstation.
Distributed delivery adds another local constraint. Brisbane commonly operates in Brisbane time, while Perth remains on UTC+8 and Sydney changes between UTC+10 and UTC+11. Tests should use injected clocks or generous remote-call timeouts instead of assuming a fixed offset or wall-clock boundary. Australian English and DD/MM/YYYY conventions should also be represented correctly in fixture data and error messages.
The same lifecycle discipline appears in Geb and Spock testing, where isolated fixtures and deterministic data prevent browser tests from depending on execution order. RMI suites need the same discipline for registry names, exported objects, and system properties.
Continuous integration benefits from a reusable extension that starts the registry once per test class when safe, or per test when state isolation is more important. Test reports should include the selected registry port and bound service name, while avoiding stack traces that expose sensitive payload data.
Choosing the boundary that matches the risk
Different levels of RMI testing provide different confidence. The appropriate choice depends on whether the main risk lies in business logic, remote transport, or compatibility between separate JVMs.
| Test style | What it proves | Main blind spot | Strongest use |
|---|---|---|---|
| Direct method call | Business logic and local error handling | Registry, stubs, and wire marshalling | Fast unit tests |
| Embedded RMI registry | Lookup, stub creation, TCP invocation, and serialisation in one JVM | Separate-JVM class loading and process isolation | Service integration suites |
| External registry and separate JVMs | End-to-end deployment behaviour across runtime boundaries | Greater setup and environment complexity | Release gates and compatibility checks |
An embedded registry is the strongest compromise for most Java RMI integration tests. It proves the remote boundary without introducing a daemon that can be missing, stale, or shared accidentally between test runs. A separate-process fixture remains appropriate when class loading, firewall behaviour, or operating-system differences are central to the risk.
The behaviour to remember is simple: use the embedded registry when lookup, proxying, and wire marshalling are part of what the test must prove, and isolate every fixture so the same test can run repeatedly on Australian CI workers without relying on a globally shared port.