If you’ve written Spring Boot tests for a while, you probably have muscle memory for @MockBean. You put it on a field, Spring swaps the real bean for a Mockito mock, and you move on with your day. In Spring Boot 4 that muscle memory produces a compile error, not a deprecation warning — the annotation and the package it lived in are both gone.
That’s one half of this post. The other half is the question that usually comes right after you fix the compile error: now that your test needs to talk to a real HTTP endpoint — a payments API, a quotes service, anything outside your own process — do you really need Docker and a container just to get a URL that returns canned JSON? Most of the time you don’t. WireMock and OkHttp’s MockWebServer both give you a real HTTP server, listening on a real port, running inside the same JVM as your test, started and stopped in milliseconds. No container, no network hop outside localhost, no Testcontainers.
This post covers both: what actually replaced @MockBean, and how to fake an HTTP collaborator without a container. Both come with sharp edges that only show up when you run them for real, so most of what follows is a real failure first and the fix second.
Versions used in this post. Spring Boot 4.1.1, Spring Framework 7.0.9, Mockito 5.23.0 (Spring Boot’s own managed version), WireMock 3.13.2 via thewiremock-standaloneartifact (theorg.wiremock:wiremock4.0.0 line is beta-only as of this writing — 4.0.0-beta.1 through 4.0.0-beta.40 on Maven Central — so 3.13.2 is the real latest GA), OkHttp MockWebServer 5.5.0 viamockwebserver3-junit5, JDK 25. Neither WireMock nor MockWebServer is managed byspring-boot-dependencies, so both versions are pinned explicitly in the companion repo’spom.xml.
The two kinds of “fake” a Spring Boot test needs
It’s worth separating these clearly before writing any code, because the tools for one do nothing for the other, and mixing them up is where a lot of confused test setups come from.
Sometimes the thing you want to fake lives inside your Spring application context: a service bean, a repository, a client wrapper — some Java object that another bean depends on. You don’t want to talk to it for real; you want to control exactly what it returns and check exactly how it was called. That’s a job for Mockito, wired into the context in place of the real bean.
Other times the thing you want to fake is outside your process entirely: an HTTP API your code calls over the network. Your code doesn’t know or care that it’s a mock — it makes a real HTTP request to a real URL. What’s fake is what’s listening on the other end.
The left side of that picture is what @MockBean used to do, and what @MockitoBean does now. The right side is what WireMock and MockWebServer do. Neither one replaces the other — a lot of real test suites need both, often in the same test class.
The smallest working example: @MockitoBean instead of @MockBean
Start with the bean-faking half, because it’s the one with a direct, if renamed, replacement. Here’s a one-method service and a test that mocks it:
Source: GreetingService.java and GreetingMockitoBeanATest.java.
@SpringBootTest
class GreetingMockitoBeanATest {
@MockitoBean
GreetingService greetingService;
@Test
void mockedGreetingIsReturned() {
when(greetingService.greet("Ada")).thenReturn("yo Ada");
assertThat(greetingService.greet("Ada")).isEqualTo("yo Ada");
}
}
Swap the import from org.springframework.boot.test.mock.mockito.MockBean to org.springframework.test.context.bean.override.mockito.MockitoBean, and everything about how you use it reads almost identically to the old annotation. That’s deliberate — @MockitoBean is Spring Framework 7’s own bean-override mechanism, not a Boot-specific add-on anymore, and it was built to cover the same ground @MockBean did.
Going deeper: Spring’s own reference documentation on the bean-override mechanism covers the full set of annotations — @MockitoBean, @MockitoSpyBean, and the more general @TestBean/@TestConfiguration escape hatches — in Bean Overriding in Tests.
@MockBean doesn’t just need a replacement — it’s gone
This is worth being precise about, because “deprecated” and “removed” lead to very different debugging sessions. If you inherit an older test file, or copy a snippet from a pre-Boot-4 tutorial, here’s exactly what happens:
import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest
class LegacyMockBeanTest {
@MockBean
GreetingService greetingService;
// ...
}
Compiling that against Spring Boot 4.1.1 produces this, captured from a real javac run against the project’s own classpath (01-mockbean-removed-compiler-error.txt):
LegacyMockBeanTest.java:5: error: package org.springframework.boot.test.mock.mockito does not exist
import org.springframework.boot.test.mock.mockito.MockBean;
^
LegacyMockBeanTest.java:10: error: cannot find symbol
@MockBean
^
symbol: class MockBean
location: class LegacyMockBeanTest
2 errors
There’s no shim, no bridging artifact, no “still works but warns” grace period. The spring-boot-test-4.1.1.jar simply doesn’t contain that package. If you’re migrating a Boot 3 test suite, this is a mechanical find-and-replace — but it’s worth knowing it’s mechanical and complete, rather than discovering one stray file still on the old annotation during a CI run.
What actually changed, and why.@MockBeanwas a Spring Boot test annotation, layered on top of Spring Framework’s own test support. Framework 7 absorbed bean overriding as a first-class framework feature —@MockitoBeanand@MockitoSpyBeanlive inorg.springframework.test.context.bean.override.mockito, insidespring-testitself, not a Boot module. That’s also why they work in a plain Spring Framework test, with no Boot auto-configuration involved at all.
Going deeper: @MockitoBean and @MockitoSpyBean also carry a few members @MockBean never had — enforceOverride() and contextName() among them — for cases where you need to be explicit about which bean definition is being replaced in a multi-context test setup. Spring Boot’s own Testing Spring Boot Applications reference page covers how these interact with @SpringBootTest specifically.
What an identical override set actually buys you: the ApplicationContext cache
Here’s something that’s easy to miss if you only ever run one @SpringBootTest class at a time: Spring’s test support caches the ApplicationContext it builds, keyed on everything that configures it — the configuration classes, the active profiles, and crucially, the exact set of bean overrides a test class declares. Two test classes with the same key reuse one context. A different key, even a seemingly small one, costs a brand new context — a second Spring Boot startup, inside the same test run.
That matters for @MockitoBean specifically because it’s trivially easy to end up with ten test classes each rebuilding the same context, just because each one’s @MockitoBean declaration differs by a stray detail. Three test classes in the companion repo make the cache behavior visible with real, captured log lines rather than a claim you have to take on faith:
- GreetingMockitoBeanATest.java —
@MockitoBean GreetingService greetingService; - GreetingMockitoBeanBTest.java — the exact same declaration
- GreetingMockitoSpyBeanCTest.java —
@MockitoSpyBean GreetingServiceImpl greetingService;instead
A and B declare an identical override: same type, same default name, same reset mode. C overrides the same field conceptually, but with a spy instead of a mock — a mock replaces the real bean outright, a spy wraps it, and that’s a genuinely different thing for the context builder to construct. Turning on logging.level.org.springframework.test.context.cache=DEBUG and running all three together produces this, trimmed to the statistics lines (04-full-test-run-passing.txt):
Running com.ankurm.testingnc.GreetingMockitoSpyBeanCTest
Spring test ApplicationContext cache statistics: [DefaultContextCache@778c2e7c size = 1, maxSize = 32, contextUsageCount = 0, parentContextCount = 0, hitCount = 0, missCount = 1, failureCount = 0]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.679 s -- in com.ankurm.testingnc.GreetingMockitoSpyBeanCTest
Running com.ankurm.testingnc.GreetingMockitoBeanBTest
Spring test ApplicationContext cache statistics: [DefaultContextCache@778c2e7c size = 2, maxSize = 32, contextUsageCount = 0, parentContextCount = 0, hitCount = 11, missCount = 2, failureCount = 0]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.195 s -- in com.ankurm.testingnc.GreetingMockitoBeanBTest
Running com.ankurm.testingnc.GreetingMockitoBeanATest
Spring test ApplicationContext cache statistics: [DefaultContextCache@778c2e7c size = 2, maxSize = 32, contextUsageCount = 0, parentContextCount = 0, hitCount = 23, missCount = 2, failureCount = 0]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.050 s -- in com.ankurm.testingnc.GreetingMockitoBeanATest
Read missCount across those three runs: it goes 1, then 2, then stays at 2. C builds the first context ever (a miss, size goes to 1). B builds a second, different context — its override set doesn’t match C’s spy, so that’s a second miss, size goes to 2. A then runs and missCount doesn’t move at all: A’s override set is identical to B’s, so A reuses B’s already-built context — a cache hit, and notice A’s own elapsed time drops to 0.050s against B’s 1.195s, because there’s no Spring Boot startup to pay for. (JUnit’s default class ordering isn’t alphabetical, which is why this particular run happened to go C, B, A rather than A, B, C — the proof holds regardless of which order they run in, because it’s keyed on the override set, not on which class ran first.)
Going deeper: the override set isn’t the only thing that keys the cache — active profiles, @DirtiesContext, and configuration classes all participate too, and a context that gets marked dirty is evicted rather than reused on the next lookup. Spring’s reference documentation on context caching is the authoritative list of what’s part of the key.
Faking an HTTP collaborator: WireMock
Now the other half. Say your application calls a quotes API over HTTP, through a small RestClient wrapper:
Source: QuoteClient.java.
@Component
public class QuoteClient {
private final RestClient restClient;
public QuoteClient(@Value("${quotes.api.base-url}") String baseUrl) {
var settings = HttpClientSettings.defaults().withReadTimeout(Duration.ofMillis(300));
this.restClient = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
.build();
}
public Quote getQuote(long id) {
return restClient.get()
.uri("/quotes/{id}", id)
.retrieve()
.body(Quote.class);
}
}
Testing this against the real quotes API would be slow, flaky, and would fail the moment you’re offline. WireMock gives you a real embedded HTTP server you point QuoteClient at instead, with a JUnit 5 extension that starts and stops it around your test:
Source: QuoteClientWireMockTest.java.
class QuoteClientWireMockTest {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance().build();
@Test
void returnsTheStubbedQuote() {
wireMock.stubFor(get(urlEqualTo("/quotes/42")).willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":42,\"text\":\"Talk is cheap. Show me the code.\",\"author\":\"Linus Torvalds\"}")));
var client = new QuoteClient(wireMock.baseUrl());
Quote quote = client.getQuote(42);
assertThat(quote.text()).isEqualTo("Talk is cheap. Show me the code.");
}
}
Notice there’s no @SpringBootTest anywhere in that class — QuoteClient only needs a base URL string to construct, so this is a plain JUnit 5 test with no Spring context involved at all. “Without containers” in this post’s title means no Docker; it doesn’t mean no Spring, but it also doesn’t require Spring where the thing under test doesn’t need it.
The happy path above is the easy five minutes. The part worth real attention is what happens when you ask WireMock to simulate the collaborator actually failing — because that’s the whole reason to reach for this instead of a real integration environment. Both tests below are the rest of the same QuoteClientWireMockTest.java:
@Test
void connectionResetByPeerSurfacesAsResourceAccessException() {
wireMock.stubFor(get(urlEqualTo("/quotes/99")).willReturn(aResponse()
.withFault(Fault.CONNECTION_RESET_BY_PEER)));
var client = new QuoteClient(wireMock.baseUrl());
assertThatThrownBy(() -> client.getQuote(99))
.isInstanceOf(ResourceAccessException.class);
}
@Test
void fixedDelayPastTheReadTimeoutSurfacesAsResourceAccessException() {
wireMock.stubFor(get(urlEqualTo("/quotes/7")).willReturn(aResponse()
.withFixedDelay(1000)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":7,\"text\":\"late\",\"author\":\"nobody\"}")));
var client = new QuoteClient(wireMock.baseUrl());
assertThatThrownBy(() -> client.getQuote(7))
.isInstanceOf(ResourceAccessException.class);
}
Fault.CONNECTION_RESET_BY_PEER is WireMock’s named API for “the connection just dies” — no body, no clean close, exactly what a crashed upstream looks like from the client’s side. withFixedDelay is simpler: it just makes the stub slow, which is how you prove your client’s timeout configuration actually does something rather than being dead configuration nobody ever triggers. QuoteClient is built with a 300ms read timeout specifically so this test can cross it on purpose.
Don’t assert on the exception’s message text.ClientHttpRequestFactoryBuilder.detect()picks whichever HTTP client implementation is actually on the classpath, and that choice changes the wording of a timeout failure: the JDKHttpClientreports a cancelled request asjava.net.http.HttpTimeoutException: Request cancelled, while Apache HttpClient 5 would saySocketTimeoutException: Read timed out. Both get wrapped asResourceAccessExceptionby Spring — that type is the stable thing to assert on, not the message.
Going deeper: the full catalogue of named faults — EMPTY_RESPONSE, MALFORMED_RESPONSE_CHUNK, RANDOM_DATA_THEN_CLOSE alongside the connection-reset one used here — is in WireMock’s own fault injection documentation.
The WireMock dependency trap: two ways to get it wrong, one way to get it right
This section exists because the straightforward path — add org.wiremock:wiremock as a test dependency and go — doesn’t work in the 3.x line, and the first fix most people reach for makes things worse, not better. Both failures below are real, captured runs against this exact test class, not a description of a bug report.
Attempt one: the bare wiremock artifact. WireMock 3.x modularized its HTTP server out of the base artifact. Add only org.wiremock:wiremock and run the same test above, and the extension fails before your test body ever runs (02-wiremock-bare-fatalstartup.txt):
com.github.tomakehurst.wiremock.common.FatalStartupException: Jetty 11 is not present and no suitable HttpServerFactory extension was found. Please ensure that the classpath includes a WireMock extension that provides an HttpServerFactory implementation. See http://wiremock.org/docs/extending-wiremock/ for more information.
at com.github.tomakehurst.wiremock.http.HttpServerFactoryLoader.couldNotFindSuitableServerException(HttpServerFactoryLoader.java:85)
at com.github.tomakehurst.wiremock.WireMockServer.<init>(WireMockServer.java:72)
at com.github.tomakehurst.wiremock.junit5.WireMockExtension.startServerIfRequired(WireMockExtension.java:233)
Fair enough, the error even names the fix: add a WireMock extension that supplies an HttpServerFactory. For WireMock 3.x on a modern JDK, that’s org.wiremock:wiremock-jetty12.
Attempt two: adding wiremock-jetty12 on top. This is the natural next step, and it’s where it gets genuinely interesting. Add wiremock-jetty12 alongside the bare wiremock artifact, and the extension now finds a server factory — and then blows up inside it, with a different and much less self-explanatory error (03-wiremock-plus-jetty12-noclassdef.txt):
java.lang.NoClassDefFoundError: org/eclipse/jetty/io/WriteFlusher$Listener
at com.github.tomakehurst.wiremock.jetty12.Jetty12HttpServer.createHttpConnector(Jetty12HttpServer.java:88)
at com.github.tomakehurst.wiremock.jetty.JettyHttpServer.<init>(JettyHttpServer.java:79)
at com.github.tomakehurst.wiremock.jetty12.Jetty12HttpServer.<init>(Jetty12HttpServer.java:74)
at com.github.tomakehurst.wiremock.WireMockServer.<init>(WireMockServer.java:75)
Caused by: java.lang.ClassNotFoundException: org.eclipse.jetty.io.WriteFlusher$Listener
Running mvn dependency:tree at this point explains it. The bare wiremock artifact’s own transitive dependencies still drag in genuine Jetty 11 modules — jetty-servlet, jetty-servlets, jetty-webapp, and http2-server, all at version 11.0.26 — sitting right alongside the Jetty 12 modules that jetty-jetty12 and wiremock‘s own jetty-server dependency bring in. Maven’s dependency mediation then has to pick one version of a shared artifact like jetty-io for the whole classpath, and whichever version it picks, something written against the other major version is missing a class it needs. WriteFlusher$Listener is just the first class that happens to be absent — it isn’t a meaningful class on its own, it’s a symptom of two incompatible Jetty generations sharing one classpath.
The actual fix: wiremock-standalone. This artifact is a single shaded jar with its own relocated copy of Jetty, packaged under wiremock.org.eclipse.jetty.* instead of the normal org.eclipse.jetty.* package. Nothing else on your classpath can collide with it, because as far as the JVM is concerned it isn’t the same class at all — this is the real dependency block from the repo’s pom.xml:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.13.2</version>
<scope>test</scope>
</dependency>
Swap to just that one dependency, with nothing else from WireMock on the classpath, and the same QuoteClientWireMockTest passes cleanly — the full, passing run is in 04-full-test-run-passing.txt. WireMockExtension, the com.github.tomakehurst.wiremock.client.WireMock static imports, everything about how you write the test, stays identical — only the dependency changes.
Going deeper: this is a one-module repo, so the collision above comes entirely from WireMock’s own dependency graph. The same shape of problem — two versions of the same artifact landing on one classpath because something buried three levels down pulled in a different one — is worth knowing how to read generally with mvn dependency:tree; Maven’s own dependency:tree documentation covers the flags for narrowing it to one artifact, which is how this one was actually diagnosed.
Faking the same collaborator with MockWebServer
WireMock isn’t the only option for this. OkHttp ships MockWebServer: a real, lower-level stub HTTP server with no named “fault” API at all — instead you work directly with the raw primitives of an HTTP response (headers, body, and now the socket underneath them), and the JUnit 5 flavor manages the server’s lifecycle for you with a field annotation:
Source: QuoteClientMockWebServerTest.java.
class QuoteClientMockWebServerTest {
@StartStop
MockWebServer server = new MockWebServer();
@Test
void returnsTheStubbedQuote() throws InterruptedException {
server.enqueue(new MockResponse.Builder()
.addHeader("Content-Type", "application/json")
.body("{\"id\":42,\"text\":\"Talk is cheap. Show me the code.\",\"author\":\"Linus Torvalds\"}")
.build());
var client = new QuoteClient(server.url("/").toString());
Quote quote = client.getQuote(42);
assertThat(quote.text()).isEqualTo("Talk is cheap. Show me the code.");
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
assertThat(recorded.getTarget()).isEqualTo("/quotes/42");
}
}
@StartStop is MockWebServer’s JUnit 5 lifecycle annotation — the field gets started before the test and shut down after, no @RegisterExtension ceremony required. server.takeRequest(...) is worth calling out too: it’s MockWebServer handing you back the actual request your client sent, which is how the assertion above confirms QuoteClient hit the path you expected, not just that it got a response back.
Going deeper: MockWebServer’s own module README, straight from the OkHttp source tree, is the primary reference for the full request/response recording API — see the mockwebserver module’s README.
Really severing a connection, not just sending broken JSON
This is the section that took the most trial and error to get right, and it’s a genuinely useful lesson about what “simulating a network failure” actually means.
The first thing most people try, including an earlier draft of this exact test, is: enqueue a response with a deliberately truncated body, like {"id":99,"tex with no closing. The intent is to simulate a connection that died partway through. Here’s what that actually produces once you assert on it (05-truncated-body-wrong-exception.txt):
org.springframework.web.client.RestClientException: Error while extracting response for type [com.ankurm.testingnc.Quote] and content type [application/json]
Not a connection failure at all — a JSON parsing failure. And once you think about what actually happened on the wire, that makes complete sense: OkHttp sent a short, complete, successfully-delivered HTTP response. The client received every byte it was promised, closed the body stream normally, and only then did Spring’s Jackson message converter try to parse {"id":99,"tex as JSON and fail. That’s a real and useful failure mode to test — but it’s not a severed connection, and asserting ResourceAccessException against it would be asserting something that happened for the wrong reason, which is the kind of test that passes today and still doesn’t protect you from the actual bug you meant to catch.
Genuinely breaking the connection needs one of MockWebServer’s lower-level socket primitives. MockResponse.Builder exposes a family of them — onRequestStart, onRequestBody, onResponseStart, onResponseBody, onResponseEnd, each taking a SocketEffect to run at that exact point in the exchange, plus a simpler shutdownServer(boolean) and a failHandshake(). SocketEffect.ShutdownConnection is the one that actually tears the TCP connection down — this is the fixed version of the same test, in the same QuoteClientMockWebServerTest.java:
@Test
void socketClosedMidResponseSurfacesAsResourceAccessException() {
server.enqueue(new MockResponse.Builder()
.addHeader("Content-Type", "application/json")
.onResponseStart(SocketEffect.ShutdownConnection.INSTANCE)
.build());
var client = new QuoteClient(server.url("/").toString());
assertThatThrownBy(() -> client.getQuote(99))
.isInstanceOf(ResourceAccessException.class);
}
onResponseStart runs that effect right as the server begins writing the response, so the client never receives a complete — or even well-formed — response at all. That’s a genuinely severed connection, and the test now passes for the right reason: the full real run is in 04-full-test-run-passing.txt, alongside the happy-path test above.
Going deeper: the full set of available effects — including Stall for a connection that just stops producing bytes without closing, and CloseStream for the HTTP/2-specific case of resetting one stream without killing the whole connection — is visible in the MockResponse.Builder javadoc.
WireMock or MockWebServer: picking one
| WireMock | MockWebServer | |
|---|---|---|
| Fault simulation | Named faults (CONNECTION_RESET_BY_PEER, etc.) — readable, limited vocabulary | Raw socket primitives (SocketEffect) — more control, more code per scenario |
| Stubbing style | Fluent matcher DSL, request/response recording, a web admin API | Simple enqueue-a-response queue, direct access to the recorded request |
| Typical use | Complex request matching, multiple stubs, verifying request shape in detail | Lightweight single-collaborator tests, especially when you need exact socket-level control |
| Setup weight | Needs the right server-extension artifact (see above) — or just use wiremock-standalone | Lighter dependency footprint; the JUnit 5 artifact is close to drop-in |
Both are real, both run in-process, and both are faster and more deterministic than anything that needs Docker. Picking between them is mostly a question of whether you want WireMock’s richer matching and admin features, or MockWebServer’s more direct access to the wire.
Should you still reach for Testcontainers?
Yes, for the things that aren’t HTTP. Nothing in this post replaces Testcontainers for a real Postgres, a real Kafka broker, or a real Redis instance — those have wire protocols and failure modes that a stub server doesn’t meaningfully reproduce, and @ServiceConnection wiring a real container is the right call there. What WireMock and MockWebServer replace is specifically the case of another service you talk to over HTTP — and for that one case, a container is usually solving a problem you don’t have. If your “integration test” is really just “does my client handle a 500 and a timeout correctly”, you don’t need Docker for that question.
The honest trade-off: neither tool proves the real upstream API behaves the way your stub says it does. A contract test or a scheduled run against a staging environment still earns its place for that. What these tools buy you is a fast, deterministic, offline-capable test for your own code’s reaction to a set of HTTP outcomes you get to choose — which is usually the thing unit tests are actually failing to cover.
Further reading
- Companion repository: testing-no-containers on this blog’s Gitea
- Kotlin with Spring Boot 4.1 — where
TestRestTemplate‘s removal fromspring-boot-testwas first verified; its replacement,RestTestClient, is covered in RestTestClient in Spring Framework 7 - RestTemplate to RestClient Migration Guide
- Spring Framework reference: Bean Overriding in Tests
- Spring Framework reference: Context Caching
- Spring Boot reference: Testing Spring Boot Applications
- WireMock: Extending WireMock (fault injection and server extensions)
- OkHttp: MockWebServer module README
- OkHttp: MockResponse.Builder javadoc
No Comments yet!