Unit tests tell you that a class works correctly in a vacuum. End-to-end tests tell you that a full distributed system holds together — but they're slow, flaky, and expensive to maintain. In a microservices architecture, the most valuable tests live between those two extremes: component tests. A component test deploys your service as a real process, replaces every external dependency with a controlled substitute, and fires real HTTP requests at it. You get the confidence of an integration test at a fraction of the cost.
What a component test actually is
A component test draws a firm boundary around a single service — in this case your Order Service — and treats everything outside that boundary as something you control. Here is what is real and what is not:
What runs for real:
- Your Spring Boot application, started with a live embedded Tomcat
- Your PostgreSQL database, running inside a Docker container managed by Testcontainers
What is stubbed:
- User Service — replaced by a WireMock HTTP server on a fixed port
- Product Service — replaced by another WireMock server
- Any message queues or async consumers — replaced with lightweight in-process stubs
The test fires actual HTTP requests at your service and asserts on the HTTP response. Your service goes through every layer — controller, service class, repository, database — using real code. Only the network boundaries to other microservices are intercepted.
The anatomy of a Spring Boot component test
Here is a complete, working example for an Order Service that depends on a User Service and a Product Service over HTTP.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceComponentTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("orders_test");
@Autowired
private TestRestTemplate restTemplate;
private WireMockServer userService;
private WireMockServer productService;
@DynamicPropertySource
static void dbProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@BeforeEach
void setup() {
userService = new WireMockServer(wireMockConfig().port(8081));
userService.start();
productService = new WireMockServer(wireMockConfig().port(8082));
productService.start();
}
@AfterEach
void teardown() {
userService.stop();
productService.stop();
}
@Test
void shouldCreateOrderWhenUserAndProductExist() {
userService.stubFor(get("/users/42")
.willReturn(okJson("""
{"id":42,"name":"Alice","email":"alice@example.com"}
""")));
productService.stubFor(get("/products/100")
.willReturn(okJson("""
{"id":100,"name":"Laptop","price":999.99,"stock":5}
""")));
var request = new CreateOrderRequest(42L, 100L, 1);
var response = restTemplate.postForEntity("/orders", request, OrderResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getUserId()).isEqualTo(42L);
assertThat(response.getBody().getStatus()).isEqualTo("PENDING");
}
}Breaking down each moving part
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) starts the full application context with a real HTTP server. TestRestTemplate is injected and pre-configured to talk to that server — so every call goes through Tomcat, your filters, your controllers, the whole stack.
@Testcontainers + PostgreSQLContainer asks Testcontainers to start a real PostgreSQL 15 container before the test class runs and stop it afterwards. The @DynamicPropertySource method wires the container's generated JDBC URL, username, and password into the Spring environment at startup time. Your JPA repositories and Flyway migrations run against this real database — every SQL statement executes.
WireMockServer is an embedded HTTP server that runs in the same JVM as your test. You tell it which requests to match and what to return. Your Order Service's HTTP client (RestTemplate, WebClient, Feign — whatever you use) is configured to point at localhost:8081 and localhost:8082. It calls WireMock thinking it is calling the real User Service and Product Service.
TestRestTemplate.postForEntity fires a real HTTP POST to your service with a serialised request body. The response is deserialised back into your response DTO. You assert on the status code and the body fields exactly as a consumer of your API would.
How fast are these tests?
The first time Testcontainers pulls a Docker image the test will be slower. Once the image is cached locally, container startup takes roughly 2–4 seconds per test class. Individual test methods within that class run in 300–500 ms — because there is no network latency to real external services, WireMock responds in microseconds.
Running 50 component tests for a service takes around 30 seconds. That is fast enough to run in every CI pull request pipeline. Compare that to the same coverage via end-to-end tests against a shared staging environment: unpredictable latency, shared state between test runs, and 10–30 minutes per run.
Component tests versus integration tests
The terms are used loosely in the industry, but the distinction that matters is scope:
- An integration test connects your service to real downstream dependencies. It catches real compatibility problems but is slow, requires those services to be running and seeded, and breaks when those services break.
- A component test (as defined here) uses WireMock for every downstream service. It cannot catch incompatibilities between your stub and the real service — that is what contract tests cover. But it gives you comprehensive coverage of your own service's behaviour: happy paths, error handling, edge cases, and persistence logic.
Both have a place. Component tests handle the bulk of behavioural coverage. A small suite of contract tests (Pact) then verifies stub compatibility. True integration tests are reserved for critical paths only.
The same pattern in other languages
The approach is not Java-specific. The tools change but the idea is identical:
- Node.js:
testcontainers-node+nock(or WireMock via Docker) +supertestfor HTTP assertions - Python:
testcontainers-python+responseslibrary for HTTP stubs +httpxorrequestsfor calling your service - Go:
testcontainers-go+httptest.NewServerfor dependency stubs
If you work in a polyglot organisation, understanding the pattern means you can apply it in any stack.
The execution flow
Step 1 of 5
Start infrastructure
Testcontainers spins up a real PostgreSQL container. WireMock servers start on configured ports, ready to serve stubbed responses.
⚠️ Common mistakes
- Sharing WireMock state between tests. If you configure stubs in a
@BeforeAllblock and multiple tests add conflicting stubs, tests interfere with each other. Start and stop WireMock per test method in@BeforeEach/@AfterEach, and callresetAll()between tests if you use a shared server. - Asserting only on the HTTP response, not the database. The whole point of running a real database is to verify persistence. Inject a
JdbcTemplateor repository into your test and assert that the expected rows actually exist — don't trust the response body alone. - Using fixed ports for WireMock across parallel tests. If your CI runs tests in parallel, multiple test classes fighting over port 8081 will cause intermittent failures. Use
wireMockConfig().dynamicPort()and pass the assigned port to your service via@DynamicPropertySource.
🎯 Practice task
Apply the component test pattern to a service you own or a sample project. Five steps:
- Pick a service that calls at least one external HTTP dependency. Add
spring-boot-test,testcontainers-postgresql, andwiremock-standaloneto your test dependencies. - Write a
@SpringBootTestclass with aPostgreSQLContainerand aWireMockServer. Use@DynamicPropertySourceto wire the database URL into the Spring context. - Implement the happy-path test: stub the HTTP dependencies with realistic response bodies, POST a valid request to your service, and assert on the response status and body.
- Add a second test for an error path — for example, stub the User Service to return a 404 and assert that your service returns the appropriate error response (e.g.
400 Bad Requestor404 Not Found). - Run
./mvnw test -Dtest=YourComponentTestand confirm both tests pass. Check the test output for the container startup time. Then run the suite twice more and observe how the cached image makes subsequent runs faster.