Spring has first-class tools for faking HTTP inside the test JVM: WireMock embedded in a JUnit test is the community standard, MockRestServiceServer is Spring's own interceptor for RestClient/RestTemplate, and @MockBean replaces your client bean outright. For millisecond unit tests they're usually the right choice โ this guide's comparison table says so plainly.
But an in-process fake can't help when you need a URL that outlives one test run:
@Retryable/timeout policy should be exercised against a server that actually returns 503s slowly over the network, not an interceptor that never leaves the JVM;The idiomatic Spring fix is almost embarrassingly small: your base URL already lives in a property, so one line of application-test.properties points the whole app at a hosted mock. Every snippet below was run verbatim against the live demo project before publishing (Spring Boot 3.3, Java 17 โ RestClient needs Boot 3.2+; on older Boot the same properties trick works with RestTemplate/WebClient).
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"spring-demo","preset":"ecommerce"}'
# โ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}
The ecommerce preset seeds products, orders, customers and reviews with full CRUD, filtering, sorting, search and pagination. The create response also includes a dashboard link that opens the project in a web UI. The snippets below use the public demo project so they run with zero setup.
You've probably already written your client this way โ the base URL comes from configuration:
# src/main/resources/application.properties
# production: catalog.base-url=https://api.your-real-vendor.com
catalog.base-url=https://mockbird.mockbird.workers.dev/m/demo
package com.example.demo;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class CatalogClient {
private final RestClient http;
public CatalogClient(RestClient.Builder builder,
@Value("${catalog.base-url}") String baseUrl) {
this.http = builder.baseUrl(baseUrl).build();
}
public List<Product> topByPrice(int n) {
return http.get()
.uri("/products?sortBy=price&order=desc&limit={n}", n)
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<>() {});
}
public ResponseEntity<List<Product>> page(int page, int limit) {
return http.get()
.uri("/products?page={p}&limit={l}", page, limit)
.retrieve()
.toEntity(new org.springframework.core.ParameterizedTypeReference<>() {});
}
public Product byId(int id) {
return http.get().uri("/products/{id}", id).retrieve().body(Product.class);
}
public Product create(Product p) {
return http.post().uri("/products").body(p).retrieve().body(Product.class);
}
public void delete(int id) {
http.delete().uri("/products/{id}", id).retrieve().toBodilessEntity();
}
}
And the entire "mocking strategy" is one file:
# src/test/resources/application-test.properties
# Point every client at the hosted mock instead of the real API.
catalog.base-url=https://mockbird.mockbird.workers.dev/m/demo
Run with @ActiveProfiles("test") (or spring.profiles.active=test in CI) and every request goes to the mock. No interceptors, no stub DSL, no conditional beans. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ see the parameter reference.
Spring 6's declarative HTTP interfaces bind to whatever base URL the underlying RestClient got โ so they hit the mock in tests for free:
package com.example.demo;
import java.util.List;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface ProductsApi {
@GetExchange("/products")
List<Product> list(@RequestParam int limit, @RequestParam String sortBy);
@GetExchange("/products/{id}")
Product byId(@PathVariable int id);
@PostExchange("/products")
Product create(@org.springframework.web.bind.annotation.RequestBody Product p);
}
package com.example.demo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class HttpClientsConfig {
@Bean
ProductsApi productsApi(RestClient.Builder builder,
@Value("${catalog.base-url}") String baseUrl) {
RestClient rc = builder.baseUrl(baseUrl).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(rc))
.build()
.createClient(ProductsApi.class);
}
}
package com.example.demo;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record Product(Integer id, String name, Double price, Boolean inStock) {}
package com.example.demo;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("test")
class CatalogClientTest {
@Autowired CatalogClient catalog;
@Autowired ProductsApi productsApi;
@Test
void paginationSendsRealHeaders() {
ResponseEntity<List<Product>> page2 = catalog.page(2, 5);
assertEquals(5, page2.getBody().size());
assertEquals("30", page2.getHeaders().getFirst("X-Total-Count")); // real header
}
@Test
void sortingHappensServerSide() {
List<Product> top = catalog.topByPrice(3);
assertEquals(3, top.size());
assertTrue(top.get(0).price() >= top.get(1).price());
assertTrue(top.get(1).price() >= top.get(2).price());
}
@Test
void writesPersistAndReadBack() {
Product created = catalog.create(new Product(null, "Test Widget", 9.99, true));
assertNotNull(created.id());
Product fetched = catalog.byId(created.id()); // it's really there
assertEquals("Test Widget", fetched.name());
catalog.delete(created.id()); // clean up
}
@Test
void declarativeInterfaceClientWorks() {
List<Product> three = productsApi.list(3, "price");
assertEquals(3, three.size());
}
}
All four pass against the live demo project. X-Total-Count is a real response header (CORS-exposed, so browsers see it too), and the write test proves persistence: the POST stores a record you can GET back โ then DELETE to clean up. No stub said "return 30"; the server counted.
Append ?mock_status=503 to force any status, or ?mock_delay=3000 to make the mock respond slowly โ over the real network, which is exactly what your timeout config guards against:
package com.example.demo;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
@SpringBootTest
@ActiveProfiles("test")
class SadPathsTest {
@Autowired RestClient.Builder builder;
@Value("${catalog.base-url}") String baseUrl;
@Test
void serviceUnavailableSurfacesAsTypedException() {
RestClient http = builder.baseUrl(baseUrl).build();
HttpServerErrorException.ServiceUnavailable ex =
assertThrows(HttpServerErrorException.ServiceUnavailable.class, () ->
http.get().uri("/products?mock_status=503").retrieve().body(String.class));
assertEquals(503, ex.getStatusCode().value());
}
@Test
void slowUpstreamHitsARealReadTimeout() {
SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
f.setReadTimeout(1000); // your production timeout policy
RestClient http = RestClient.builder().baseUrl(baseUrl).requestFactory(f).build();
assertThrows(ResourceAccessException.class, () ->
http.get().uri("/products?mock_delay=3000").retrieve().body(String.class));
}
}
Both pass: the 503 surfaces as Spring's typed HttpServerErrorException.ServiceUnavailable, and the 3-second response trips a genuine ResourceAccessException (socket read timeout) โ not a simulated one. Sweep mock_status through 400/401/403/404/429/500 to test your whole error-handling matrix; see testing loading & error states.
?mock_chaos=0.5 makes the mock fail half of all requests with a random 5xx/429 โ the retry annotation on your client method has to earn its keep. Add org.springframework.retry:spring-retry + org.springframework:spring-aspects to the pom and put @EnableRetry on your application class, then on the client (imports: Retryable, Backoff, HttpServerErrorException, HttpClientErrorException):
@Retryable(retryFor = { HttpServerErrorException.class, // 5xx
HttpClientErrorException.TooManyRequests.class }, // 429
maxAttempts = 6, backoff = @Backoff(delay = 200))
public List<Product> topByPriceResilient(int n, double chaos) {
return http.get()
.uri("/products?sortBy=price&order=desc&limit={n}&mock_chaos={c}", n, chaos)
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<>() {});
}
package com.example.demo;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("test")
class RetryTest {
static final AtomicInteger failuresAbsorbed = new AtomicInteger();
@TestConfiguration
static class CountRetries {
@Bean
RetryListener retryCounter() {
return new RetryListener() {
@Override
public <T, E extends Throwable> void onError(
RetryContext ctx, RetryCallback<T, E> cb, Throwable t) {
failuresAbsorbed.incrementAndGet();
}
};
}
}
@Autowired CatalogClient catalog;
@Test
void retryableAbsorbsInjectedChaos() {
for (int i = 0; i < 5; i++) {
// mock_chaos=0.5 โ half of these upstream calls return a random 5xx/429
assertEquals(1, catalog.topByPriceResilient(1, 0.5).size());
}
System.out.println("injected failures absorbed by @Retryable: "
+ failuresAbsorbed.get());
}
}
When we ran it: all five calls returned data, and the listener printed injected failures absorbed by @Retryable: 7 โ seven real 5xx/429 responses came back over the wire and the annotation retried through every one. Chaos-failed writes are not applied server-side ("server died before processing" semantics), so it's safe to point retrying write logic at it too. Pin the failure pool with mock_chaos_status=503,429 if you only retry specific codes.
Snapshots freeze a named copy of the whole dataset. Any client that sends X-Mockbird-Snapshot: name reads that frozen data โ while other suites, teammates and the live dataset stay untouched. Empty-state tests without wiping anything:
package com.example.demo;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.client.RestClient;
@SpringBootTest
@ActiveProfiles("test")
class SnapshotPinningTest {
@Value("${catalog.base-url}") String baseUrl;
@Test
void emptySnapshotServesZeroRecordsWhileLiveDataIsUntouched() {
// Every request from this client is pinned to the frozen "empty" snapshot โ
// perfect for empty-state tests running in parallel with other suites.
RestClient pinned = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("X-Mockbird-Snapshot", "empty")
.build();
List<Product> fromSnapshot = pinned.get().uri("/products").retrieve()
.body(new ParameterizedTypeReference<>() {});
assertEquals(0, fromSnapshot.size());
RestClient live = RestClient.builder().baseUrl(baseUrl).build();
List<Product> fromLive = live.get().uri("/products").retrieve()
.body(new ParameterizedTypeReference<>() {});
assertTrue(fromLive.size() > 0); // live dataset still fully there
}
}
Create snapshots from the dashboard or POST /api/projects/:id/snapshots {"name":"empty"} โ see deterministic test data for the full pattern (baseline / empty / edge-cases / bug-repro).
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| WireMock (embedded) | MockRestServiceServer | @MockBean stubs | Mockbird | |
|---|---|---|---|---|
| What it is | Full mock server in your JVM, rich matching DSL | Spring's interceptor โ no socket, rewires RestClient/RestTemplate | Replaces your client bean โ no HTTP at all | Hosted mock API |
| Works offline / zero latency | โ | โ (in-process) | โ | โ (real network) |
| Stubs you must write & maintain | Every stub, in their DSL | Every expect(...), by hand | Every when(...), by hand | None โ CRUD, filters, search, pagination built in |
| Reachable by teammates, frontend, CI, other services | โ one JVM (Cloud version is $$$) | โ one JVM | โ one JVM | โ one https URL |
| Real network timeouts / slow responses / rate limits | โ fixed delays, fault injection | โ nothing crosses a socket | โ nothing is real | โ mock_delay / mock_chaos / mock_ratelimit |
| State persists across JVMs/runs | โ (scenarios reset) | โ | โ | โ (+ snapshots) |
| Verify "was this called?" | โ its superpower | โ verify() | โ Mockito verify(...) | Partial: request inspector |
| Free tier | free (library) | free (ships with Spring) | free (ships with Boot) | free: 20 projects, 10k req/project/day |
Use both: keep MockRestServiceServer or WireMock for millisecond-fast unit tests with request verification, and point integration tests, demos, teaching materials and not-yet-built-backend work at a hosted URL. (Evaluating WireMock Cloud specifically? We compared it honestly here.)