Spring Boot Integration Testing: Full Context, Stubbed Boundaries, Zero Flakiness

TL;DR: Boot the full Spring context. Stub only what lives outside your service boundary: Feign clients, external HTTP APIs, outbound Kafka. Then hit the service through its real HTTP endpoint and verify the entire chain: controller, validation, service logic, @Transactional, repository, database write, response serialization. This is what Spring calls an integration test. It catches the class of bugs that unit tests structurally miss: broken configs, silent serialization changes, transaction proxy bypass, security filter misconfiguration, and DTO contract drift.
What "Integration Test" Means Here
The term "integration test" is overloaded. In some teams it means two microservices talking over a network. In others it means a full E2E suite running against staging. In this article, it means something specific:
One Spring Boot service, tested as a system within its own boundaries.
The full Spring context boots. All internal beans, validators, mappers, aspects, security filters, and transaction proxies are real. The test enters through the actual HTTP endpoint (not a direct service call) and exits through a real database write. The only things that are stubbed are dependencies that live outside the service: calls to other microservices, external APIs, outbound message queues.
This matches how Spring's own documentation defines integration testing: any test that loads an ApplicationContext.
What Is Inside vs Outside the Service Boundary
The boundary is simple: everything that belongs to your deployable unit is internal. Everything that requires a network call to another team's system is external.
Internal (real in the test):
- The entire Spring context: all beans, all auto-configuration
- Business logic: services, domain rules, calculations
- Data access: repositories, JPA mappings, SQL queries
- Infrastructure:
@Transactionalboundaries,@Validated,@Cacheable - HTTP layer: controllers, filters, exception handlers, serialization
- Adapters: mappers, converters, aspects, event listeners
External (stubbed in the test):
- Feign clients or WebClient calls to other microservices
- REST calls to third-party APIs (Stripe, CRM, payment gateways)
- Outbound Kafka/RabbitMQ messages (when the test focuses on correct payload formation, not delivery)
- Any dependency that introduces network latency, rate limits, or data you don't control
The principle: we do not "cut" the internal chain. We only replace the responses of the outside world so the entire logic inside the service runs end-to-end.
Why the Full Spring Context Matters
You can have 100% unit test coverage and still hit production with these bugs. Every one of them lives at the seams that unit tests mock away:
ObjectMappermisconfiguration. Dates serialize as timestamps instead of ISO strings. Enums serialize as ordinals. Null handling policy silently changes.- Validation not applied.
@Validatedis missing on the controller parameter, or the wrongValidatorbean is active. - Transaction proxy bypass. A
@Transactionalmethod is called from within the same bean. The proxy never intercepts it. The transaction never opens. - Repository query vs real schema. A JPQL query is syntactically valid but fails against the actual column types or naming conventions in the database.
- DTO mapping breaks. A field rename in the request DTO silently breaks the JSON contract. Jackson ignores the unknown field by default.
- Security filters block the request. A Spring Security rule change starts rejecting requests that used to pass.
- Aspect side effects. A logging or metrics aspect modifies behavior, swallows exceptions, or changes the execution order.
- External client configuration.
RestTemplateorWebClientsends the wrong headers, wrong timeouts, or the wrong base URL fromapplication.yml. - Response serialization mismatch. The HTTP response body differs from the contract the frontend expects.
Unit tests stay green through all of these because they mock the infrastructure where these bugs live. Integration tests exercise the real infrastructure. That is the entire point.
How to Stub External Dependencies in Spring Boot
External dependencies enter your code in one of three forms. Each has a clean stubbing pattern.
Pattern 1: Feign Client or a Java Interface Bean
If you have a CrmClient or PaymentGateway interface injected as a Spring bean, use @MockBean:
@MockBean
CrmClient crmClient;
Spring boots the full context but replaces this one bean with a Mockito mock. Every other bean is real. This is the fastest and most common approach.
Pattern 2: RestTemplate or WebClient with a Base URL
If your service calls an external API through RestTemplate or WebClient, you have two options:
- Replace the bean with
@MockBean(same as above). - Point the base URL to a local WireMock or MockWebServer instance. This tests the real HTTP serialization, headers, and error handling against a controlled stub.
server.enqueue(new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/json")
.setBody("{\"id\":\"42\",\"status\":\"OK\"}"));
Option 2 is more realistic because it exercises the actual HTTP stack. Use it when serialization or error handling is part of the risk.
Pattern 3: Outbound Message Queues
For Kafka or RabbitMQ producers, the test usually verifies that the correct event payload was formed, not that it was delivered. You can:
@MockBeanthe producer and verify the call withverify(producer).send(...).- Use an embedded broker via Testcontainers if the consumer logic is part of the critical chain.
The rule in all three patterns: stub only what crosses the service boundary. Never mock internal services or repositories for convenience. That defeats the purpose of running the full chain.
Full Example: Testing a Policy Signing Flow
A realistic microservice scenario: POST /api/policy/sign triggers validation, domain logic, a database write, and a call to an external CRM service for customer data. The CRM is the only external dependency.
The Service Code
public interface CrmClient {
CustomerDto getCustomer(String customerId);
}
@Service
public class PolicyService {
private final CrmClient crmClient;
private final PolicyRepository policyRepository;
public PolicyService(CrmClient crmClient,
PolicyRepository policyRepository) {
this.crmClient = crmClient;
this.policyRepository = policyRepository;
}
@Transactional
public SignResponse sign(SignRequest req) {
CustomerDto customer = crmClient.getCustomer(req.customerId());
PolicyEntity policy = new PolicyEntity();
policy.setContractId(req.contractId());
policy.setCustomerSegment(customer.segment());
policy.setStatus("SIGNED");
policyRepository.save(policy);
return new SignResponse("SIGNED");
}
}
@RestController
@RequestMapping("/api/policy")
public class PolicyController {
private final PolicyService policyService;
public PolicyController(PolicyService policyService) {
this.policyService = policyService;
}
@PostMapping("/sign")
public SignResponse sign(@RequestBody SignRequest request) {
return policyService.sign(request);
}
}
The Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PolicyIntegrationTest {
@Autowired
TestRestTemplate rest;
@Autowired
PolicyRepository policyRepository;
@MockBean
CrmClient crmClient;
@Test
void sign_happyPath_runsFullChainAndWritesToDb() {
when(crmClient.getCustomer("42"))
.thenReturn(new CustomerDto("42", "VIP"));
ResponseEntity<SignResponse> resp = rest.postForEntity(
"/api/policy/sign",
new SignRequest("C-123", "42"),
SignResponse.class
);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().status()).isEqualTo("SIGNED");
PolicyEntity saved = policyRepository
.findByContractId("C-123").orElseThrow();
assertThat(saved.getStatus()).isEqualTo("SIGNED");
assertThat(saved.getCustomerSegment()).isEqualTo("VIP");
verify(crmClient).getCustomer("42");
}
}
This test simultaneously verifies:
- The Spring context boots and all beans wire correctly.
- The HTTP endpoint deserializes the request.
- The
CrmClientis called with the right argument. - The
@Transactionalboundary works (the repository write commits). - The domain logic maps the CRM segment to the policy entity.
- The response serializes correctly.
- The database contains the expected row after the transaction.
If any link in this chain breaks, the test fails. A unit test mocking PolicyRepository and CrmClient would stay green through most of these failures.
How BitDive Automates This
The hardest part of integration testing at scale is not writing @SpringBootTest. It is maintaining hundreds of mock setups and fixture files as the service evolves.
BitDive solves this by recording real execution traces from your running application, then replaying them as deterministic test scenarios. Instead of manually writing when(...).thenReturn(...) for every external dependency, BitDive captures the actual responses that your service received in production or staging.
The test harness is scenario-driven. You add a new test case by providing a scenario ID, not by writing Java code:
class PolicyControllerReplayTest extends ReplayTestBase {
@Override
protected List<ReplayTestConfiguration> getTestConfigurations() {
return ReplayTestUtils.fromRestApiWithJsonContentConfigFile(
Arrays.asList("0d46c175-4926-4fb6-ad2f-866acdc72996")
);
}
}
BitDive handles the rest: booting the Spring context, intercepting external boundaries, replaying recorded responses, and stabilizing non-deterministic values (timestamps, UUIDs, random numbers).
The result: integration tests built from real runtime data, not hand-crafted fixtures. Tests that work on the first run because they contain actual captured behavior. No hallucinated assertions. No mock maintenance burden.
Integration Tests from Real Runtime Data
BitDive records execution traces from Spring Boot applications and turns them into deterministic JUnit tests. No manual mock setup. Tests run with standard mvn test in any CI environment.
When Unit Tests Are Enough (and When They Are Not)
Use unit tests for pure logic that has no infrastructure ties: calculations, validations, mappings, state machines, complex transformations. If a method can run without Spring, a database, or HTTP, a unit test is the right tool.
Use integration tests when the risk lives at the seams: serialization, configuration binding, transaction management, security, database queries, and the interaction between multiple beans that Spring wires together.
The two levels reinforce each other. Unit tests reduce the number of potential root causes when an integration test fails. Integration tests guarantee that the real assembled application does not break at the seams where unit tests are structurally blind.
FAQ
How is this different from @WebMvcTest or @DataJpaTest?
Spring slice tests (@WebMvcTest, @DataJpaTest, @JsonTest) boot only a targeted piece of the context. They are faster and useful for verifying specific seams. But they do not test the full chain from HTTP to database. A @WebMvcTest does not know if your repository query works. A @DataJpaTest does not know if your controller serializes the response correctly. Full-context integration tests verify everything together.
Does this approach scale to dozens of test cases?
Yes, if you follow a scenario-driven pattern. Store mock responses and expected results as data files (JSON/YAML), not as Java code. Use a base test class that handles Spring context boot and mock wiring from scenario definitions. BitDive's ReplayTestBase implements this pattern out of the box.
Do I need Testcontainers?
Not necessarily. If your tests use an in-memory database (H2) or BitDive's replay mode (where database responses are also replayed from recorded traces), you do not need Docker. Testcontainers mode is valuable when you need to verify real PostgreSQL/MongoDB behavior, schema compatibility, or migration scripts.
Strategy Guide Testing Spring Boot Applications with BitDive
Related Reading
- Unit Tests with BitDive -- Creating deterministic JUnit tests from real traces
- Automated Regression Testing -- Turn execution traces into permanent regression checks
- BitDive vs Mockito -- Why teams move from manual mocks to recorded behavior
