โ† All guides

Mock a REST API for Spring Boot โ€” RestClient, @Retryable and @SpringBootTest against a real URL

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:

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).

1. Get an API (10 seconds, no signup)

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.

2. The one-property trick: RestClient with a configurable base URL

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.

3. Declarative HTTP interfaces (@GetExchange) work too

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);
    }
}

4. @SpringBootTest: real pagination headers, real writes

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.

5. Sad paths: typed exceptions and a real read timeout

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.

6. @Retryable absorbs injected chaos

?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.

7. Pin parallel suites to frozen snapshots

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).

8. Honest comparison

The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:

WireMock (embedded)MockRestServiceServer@MockBean stubsMockbird
What it isFull mock server in your JVM, rich matching DSLSpring's interceptor โ€” no socket, rewires RestClient/RestTemplateReplaces your client bean โ€” no HTTP at allHosted mock API
Works offline / zero latencyโœ”โœ” (in-process)โœ”โœ– (real network)
Stubs you must write & maintainEvery stub, in their DSLEvery expect(...), by handEvery when(...), by handNone โ€” 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 tierfree (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.)

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, SvelteKit, React Native, Flutter and Android at once โ€” one dataset for the whole team. Plain Java (no Spring), Ktor, Python, Node, Go or C#/.NET service in the mix too? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.