Mock server for client tests
conta-http-mock-server stands up a real HTTP server whose every route is answered by a test-supplied callback.
Point the client-under-test at it, script the responses, and assert the request the client actually sent.
It is the response side of testing a declarative @Client (or any HttpClient): no WireMock, no hand-rolled EmbeddedServer wiring.
For scripting responses without verification, or running the server inside a full application’s test classpath, see Stubbing and classpath isolation.
Installation
A test-support module — add it to testImplementation (see Getting started for the repository):
dependencies {
testImplementation 'no.conta.http:conta-http-mock-server:0.+'
}
It brings Micronaut’s Netty server transitively.
It does not bring a JSON mapper or Spock/Groovy — those come from your own test classpath: any Micronaut app test already has a JsonMapper (jackson-databind or serde), and that is what the mock server uses to bind and serialize bodies.
A test module with no mapper at all must add one (e.g. micronaut-jackson-databind).
How it works
MockController is an env-gated catch-all controller: under the mock-http-server environment it maps GET/POST/PUT/DELETE on every path and delegates each request to an injected MockHttpHandler:
HttpResponse<?> handle(HttpMethod method, String uri, HttpRequest<?> request);
The two things a test almost always discriminates on — the verb and the URI — are separate positional arguments, so an interaction matches them directly and ignores the rest with _.
No matcher helper is needed for the common case, and it reads the same under Spock, Mockito, or a hand-rolled fake.
uri is request.getUri().toString() — path and query, e.g. /instances/1/2/data?dataType=vedlegg.
The query is often the discriminating half, which is why this is the URI rather than the path.
Everything else — headers, cookies, body — is on the HttpRequest.
The routes accept any content type, so non-JSON bodies (e.g. a form-urlencoded token request) reach the handler too.
In a test the handler is a Spock Mock(): its >> return becomes the response the client receives, and its interaction count/arguments are your assertion on the outbound request.
Pull the body out in whatever shape you need with MockHttpRequests — extractBodyAsBytes, extractBodyAsString, extractBodyAsMap, or extractBodyAs(type) — plus extractQueryParams.
The Groovy extensions mirror all of them (request.extractBodyAsString()).
The controller binds the body as byte[], so the raw bytes are the one canonical form and every other shape is derived from them.
Binding a Map instead would make request.getBody(Map) work but leave a text/xml or binary body unreadable: Micronaut binds a body once and does not re-convert a consumed buffer.
A form-urlencoded body still decodes to a Map via extractBodyAsMap, using Micronaut’s own form semantics — a repeated field yields a List, an empty field yields null.
A failing assert inside a response closure is thrown on a server thread, so no test framework ever sees it: the test passes, or fails somewhere unrelated, and a broken fixture masquerades as the behavior under test.
Such a failure is therefore recorded and rethrown by MockServer.verifyNoHandlerFailure(), which MockServerSpec calls after every feature — drive it from your own teardown otherwise.
The check keys on java.lang.AssertionError, which is what Groovy, Spock, JUnit, and Mockito assertions all throw, so it is not tied to one framework.
Every other throw propagates untouched, so Micronaut maps it as usual and a handler can still stub an error response by throwing (HttpStatusException(HttpStatus.NOT_FOUND, …) reaches the client as a 404, not a 500).
Spock: extend MockServerSpec
The base spec supplies a Mock() handler and a MockServer started against it, fresh for each feature method.
A declarative @Client resolves its base URL from configuration, so point its service id at mockUrl() in a context that holds the client:
@Client(id = 'widgets')
interface WidgetClient {
@Get('/widgets/{id}')
Map<String, Object> get(String id)
}
class WidgetClientTest extends MockServerSpec {
@AutoCleanup
ApplicationContext clientContext = ApplicationContext.run(
['micronaut.http.services.widgets.url': mockUrl().toString()], Environment.TEST)
@Subject
WidgetClient client = clientContext.getBean(WidgetClient)
def 'fetches a widget'() {
when:
def widget = client.get('42')
then:
1 * handler.handle(HttpMethod.GET, '/widgets/42', _) >> HttpResponse.ok([id: '42', name: 'Anvil'])
and:
widget.name == 'Anvil'
}
}
To assert what the client sent — auth headers, the request body — read it off the request inside the response closure:
1 * handler.handle(HttpMethod.POST, '/orders', _) >> { method, uri, request ->
assert request.headers.get('Authorization') == 'Bearer t0ken'
assert request.extractBodyAsMap() == [sku: 'A1', qty: 2] // or MockHttpRequests.extractBodyAsMap(request)
HttpResponse.ok([id: '99'])
}
For a one-off request you do not need a declarative client at all — a low-level HttpClient.create(mockUrl().toURL()) pointed straight at the server is enough.
Override environments() to activate extra Micronaut environments alongside test and mock-http-server:
@Override
protected String[] environments() {
['api'] as String[]
}
Plain: use MockServer directly
MockServer is AutoCloseable and framework-agnostic — use it from JUnit, try-with-resources, or a @Shared Spock field when you want one server for the whole spec rather than one per feature:
var handler = mock(MockHttpHandler.class);
try (var server = MockServer.start(handler);
var client = HttpClient.create(server.url().toURL())) {
var response = client.toBlocking().exchange(HttpRequest.GET("/widgets/42"), Map.class);
// exercise client, assert on handler
}
start(handler, "api") adds environments; start(handler, MockServerOptions…) takes the full options object (properties, isolation — see Stubbing and classpath isolation); url(), context(), and server() expose what you need to wire a client; close() stops the server.
A declarative @Client is wired exactly as in the Spock example — set micronaut.http.services.<id>.url to server.url() in the context that holds the client.
Migrating from 0.4.x
MockHttpHandler.handle gained the uri argument, so every interaction and response closure needs one more parameter.
A Groovy spec will not fail to compile — the call is dispatched dynamically — so a stale two-argument interaction fails at test runtime with TooFewInvocationsError instead.
A Java or Mockito consumer gets a compile error, which is the easier failure to read.
// before
1 * handler.handle(HttpMethod.GET, _) >> HttpResponse.ok()
1 * handler.handle(HttpMethod.POST, _) >> { method, request -> ... }
1 * handler.handle(HttpMethod.GET, requestMatching('.*/dialogs.*')) >> ok
// after — add the uri, or match on it directly
1 * handler.handle(HttpMethod.GET, _, _) >> HttpResponse.ok()
1 * handler.handle(HttpMethod.POST, '/orders', _) >> { method, uri, request -> ... }
1 * handler.handle(HttpMethod.GET, _, requestMatching('.*/dialogs.*')) >> ok
A MockRequestMatchers constraint matches the HttpRequest, so it moves to the third position.
Where it only matched a path, matching uri directly is usually simpler now — the argument carries path and query, so a regex is only needed for a partial match.
One behavioral change to expect: a previously-green spec can start failing. An assertion that failed inside a response closure used to be invisible, and is now rethrown after the feature (see How it works). A spec that goes red on upgrade is reporting a fixture bug it was hiding before — read the assertion message rather than reverting.
Limitations
The server is intentionally thin; for v1:
-
extractBodyAsMapdecodes a JSON object or a form-urlencoded body. Array, string, and numeric bodies returnnull— useextractBodyAsString,extractBodyAsBytes, orextractBodyAs(type)instead. -
extractBodyAs(type)decodes with a defaultJsonMapper, not the application’s configured one, so a type needing custom modules or a naming strategy may not decode here. A decode failure is logged at warn and returnsnull. -
The handler is typically a Spock
Mock(), which is not thread-safe — drive the client sequentially. Concurrent in-flight requests against one server can corrupt interaction counts.