Testing Java dynamic proxies and invocation handlers

A Java dynamic proxy implements one or more interfaces at runtime, giving tests control over calls that would otherwise reach a database, payment gateway, or remote service. Its behaviour is supplied by an InvocationHandler, which receives the invoked method and its arguments before deciding what to return.

An invocation handler is easy to implement but easy to test incorrectly. A useful suite checks method routing, argument capture, return types, exception translation, object methods, and behaviour under concurrent or asynchronous calls rather than merely confirming that a proxy can be created.

This guide covers JDK proxies, Mockito, and class-based alternatives. It also explains where each approach fits in a Java service, a REST API test, or a continuous delivery pipeline.

How the proxy mechanism shapes the tests

The generated proxy contains no business logic. It implements the supplied interfaces and forwards eligible calls to the handler’s invoke method. The handler receives the proxy instance, a reflective Method, and an argument array.

interface SearchClient {
    List<String> find(String query);
}

SearchClient client = (SearchClient) Proxy.newProxyInstance(
    SearchClient.class.getClassLoader(),
    new Class<?>[] {SearchClient.class},
    (proxy, method, args) -> {
        if (method.getName().equals("find")) {
            return List.of("Java", "Testing");
        }
        throw new UnsupportedOperationException(method.getName());
    });

Method names are not always enough for dispatch. Overloaded methods can share a name while accepting different parameter types, so a production-quality handler may key decisions on the complete Method object or its parameter signature. It should also handle toString, equals, and hashCode, because those calls can reach the handler through the proxy.

A JDK proxy exposes interface methods rather than arbitrary implementation methods. Default interface methods require an explicit dispatch decision, while static and private methods are not intercepted in the same way. Tests should establish these boundaries instead of assuming every method behaves like an ordinary business call.

Build a focused invocation handler harness

A small recording handler makes routing failures visible. Instead of returning canned values directly from a test, keep the production-shaped handler and capture each interaction for later assertions. Cloning the argument array also prevents later mutation from changing the recorded call.

record Call(String method, List<Object> arguments) {}

final class RecordingHandler implements InvocationHandler {
    private final List<Call> calls = new ArrayList<>();

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
        Object[] safeArgs = args == null ? new Object[0] : args.clone();
        calls.add(new Call(method.getName(), Arrays.asList(safeArgs)));

        return switch (method.getName()) {
            case "find" -> List.of("Java", "Testing");
            default -> throw new UnsupportedOperationException(method.getName());
        };
    }
}

Create the proxy with that handler and assert both sides of its contract: the value returned to the caller and the interaction captured internally. JUnit 5 with AssertJ keeps these checks compact.

SearchClient client = (SearchClient) Proxy.newProxyInstance(
    SearchClient.class.getClassLoader(),
    new Class<?>[] {SearchClient.class},
    handler);

assertThat(client.find("proxy")).containsExactly("Java", "Testing");
assertThat(handler.calls()).singleElement().satisfies(call -> {
    assertThat(call.method()).isEqualTo("find");
    assertThat(call.arguments()).containsExactly("proxy");
});

The harness should remain smaller than the production component. A reusable proxy factory, immutable call records, and a handler with explicit dispatch rules reduce setup without hiding the behaviour under a large mocking framework.

Compare proxy testing approaches

JDK proxies, Mockito mocks, spies, and bytecode-generated subclasses solve different problems. Choose based on whether the test needs precise control of InvocationHandler behaviour, convenient stubbing, partial delegation, or interception of class methods.

Approach Best fit Assertions to focus on Important limitation
JDK proxy and custom handler Testing exact invocation-handler logic Method routing, captured arguments, returned data, thrown causes Interfaces only; object methods need deliberate handling
Mockito mock Isolating a consumer from an interface Stubbing, verification, argument capture, consecutive calls Generated behaviour may differ from a hand-written handler
Mockito spy Combining real behaviour with selected interception Calls made while delegated code executes Shared state and final methods need careful treatment
Byte Buddy or CGLIB proxy Intercepting overridable class methods Inherited method calls and overridden responses Cannot intercept final or private methods; not an interface proxy

A Mockito mock is often the quickest choice when the handler itself is not under test. If the handler contains retry logic, auditing, argument normalisation, or exception translation, however, a hand-written JDK proxy exposes those rules more directly.

The distinction matters in framework tests. Mockito’s RETURNS_DEFAULTS behaviour and a custom handler’s null return are not identical contracts, even when a simple assertion appears to pass. Testing the actual mechanism gives stronger confidence when that mechanism will run in production.

Verify calls, returns, and thrown exceptions

Argument capture should preserve nulls, primitive wrappers, and varargs arrays. A simple equality check can miss a handler that forwards the wrong overload, converts a collection into an array, or drops an identifier. Capturing the reflective Method alongside the arguments exposes those mistakes.

Mockito remains useful for testing code that consumes an interface.

SearchClient searchClient = mock(SearchClient.class);

when(searchClient.find("java")).thenReturn(List.of("JUnit 5"));

assertThat(service.search("java")).containsExactly("JUnit 5");
verify(searchClient).find("java");

Handlers that delegate to a real target need special care with exceptions. Method.invoke wraps a target failure in InvocationTargetException; rethrowing that wrapper changes the caller-visible exception and can obscure the actual cause.

try {
    return method.invoke(target, args);
} catch (InvocationTargetException exception) {
    throw exception.getCause();
}

Return values must also respect the declared type. A null result cannot be unboxed into a primitive, and an incompatible result may produce a runtime failure during proxy conversion. For asynchronous interfaces returning CompletableFuture, the handler should normally return a future and encode failures as exceptional completion rather than throwing synchronously.

A focused proxy test matrix

Core invocation checks:

Boundary and operational checks:

Object methods deserve particular attention because frameworks often call them while creating an exception, logging a dependency, or placing a proxy in a collection. If equals and hashCode produce inconsistent results, a list or map can contain duplicate logical entries or fail to retrieve an expected value.

Default methods also need deliberate handling. On modern Java versions, InvocationHandler.invokeDefault can dispatch a default method to its declared implementation. Older projects may instead route it to another method or throw an unsupported-operation exception, which should be documented and tested.

Run the checks in an Australian delivery workflow

Proxy tests belong in the normal build rather than in a separate diagnostic suite. A JUnit test can run on every pull request, while a small integration test can exercise the proxy with a real HTTP stub or MockWebServer. This gives fast feedback during local development and catches wiring errors before deployment.

The same pattern appears across distinct Australian markets. Sydney fintech teams often wrap banking and payment APIs; Melbourne software houses test retail and logistics integrations; and Canberra-based government teams require auditable boundaries around public digital-service APIs. In these regulated environments, invocation records and predictable failure behaviour are engineering evidence rather than merely debugging aids.

Australian teams are also spread across Sydney, Melbourne, Brisbane, and Perth, making asynchronous handovers and reliable CI pipelines important. The informal “fair dinkum” culture generally favours direct feedback: if a proxy hides a broken contract, a candid code review should identify it plainly. When release evidence is consolidated in workbooks, Excel productivity tips can help teams keep coverage summaries clear without obscuring failed checks.

A dependable proxy suite comes from a thin handler, a clear contract, and observable checks that run on every change before deployment.