Skip to main content
  1. Posts/

Component Tests: Real Spring Context with Mocked HTTP

Unit tests verify individual classes in isolation. Integration tests verify that the full system works end-to-end. Component tests sit between: they exercise the real application logic with real infrastructure (database, cache) but with external HTTP dependencies mocked at the transport level.

The goal is a test that can say: “given this HTTP response from service X, the application writes the correct record to MongoDB.” You need a real Spring context, a real database, and controlled HTTP responses. You do not need to call service X over the network.

Project Structure #

Component tests live in their own source set, separate from unit tests and any end-to-end tests:

src/
├── main/java/
├── test/java/          ← unit tests
└── componentTest/java/ ← component tests

In build.gradle:

sourceSets {
    componentTest {
        java.srcDirs = ['src/componentTest/java']
        resources.srcDirs = ['src/componentTest/resources']
        compileClasspath += sourceSets.main.output + sourceSets.test.output
        runtimeClasspath += sourceSets.main.output + sourceSets.test.output
    }
}

configurations {
    componentTestImplementation.extendsFrom testImplementation
    componentTestRuntimeOnly.extendsFrom testRuntimeOnly
}

tasks.register('componentTest', Test) {
    description = 'Runs component tests'
    group = 'verification'
    testClassesDirs = sourceSets.componentTest.output.classesDirs
    classpath = sourceSets.componentTest.runtimeClasspath
    useJUnitPlatform()
    shouldRunAfter test
}

check.dependsOn componentTest

Dependencies shared with unit tests (JUnit, Mockito, Spring Test) are inherited via extendsFrom. Component-test-specific dependencies — Testcontainers, WireMock if you use it — go in componentTestImplementation:

dependencies {
    componentTestImplementation 'org.springframework.boot:spring-boot-starter-test:3.5.6'
    componentTestImplementation 'org.testcontainers:testcontainers:2.0.1'
    componentTestImplementation 'org.testcontainers:mongodb:2.0.1'
    componentTestImplementation 'org.testcontainers:junit-jupiter:2.0.1'
}

Java 21. JUnit 5.14.0 is managed transitively by Spring Boot 3.5.6.

The Base Test Class #

A base class provides the shared infrastructure setup:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("componentTest")
public abstract class BaseComponentTest {

    @Container
    static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:7.0")
        .withReuse(true);

    @DynamicPropertySource
    static void mongoProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
    }

    @Autowired
    protected TestRestTemplate restTemplate;
}

withReuse(true) keeps the container running across test classes during a single Gradle run. Testcontainers uses a container hash to identify reusable containers — the same MongoDB image started with the same configuration reuses the running instance rather than starting a new one.

@ActiveProfiles("componentTest") lets you configure test-specific beans — stub API clients, relaxed security — without polluting the main configuration.

Mocking External HTTP with MockRestServiceServer #

For Spring Boot services that use RestTemplate or RestClient to call external APIs, MockRestServiceServer intercepts HTTP calls at the ClientHttpRequestFactory level:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("componentTest")
public abstract class BaseComponentTest {

    @Container
    static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:7.0")
        .withReuse(true);

    @Autowired
    protected RestTemplate externalApiRestTemplate;

    protected MockRestServiceServer mockExternalApi;

    @DynamicPropertySource
    static void mongoProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
    }

    @BeforeEach
    void setUpMockServer() {
        mockExternalApi = MockRestServiceServer
            .bindTo(externalApiRestTemplate)
            .ignoreExpectOrder(true)
            .build();
    }

    @AfterEach
    void verifyMockServer() {
        mockExternalApi.verify();
        mockExternalApi.reset();
    }
}

ignoreExpectOrder(true) allows expectations to be satisfied in any order — useful when the test setup involves parallel HTTP calls or a non-deterministic calling sequence.

verify() in @AfterEach confirms every expected HTTP request was actually made. reset() clears expectations so the next test starts clean.

Writing a Component Test #

class ReportProcessingComponentTest extends BaseComponentTest {

    @Autowired
    private ReportRepository reportRepository;

    @Test
    void processingCreatesAuditRecord() {
        // Arrange: set up expected HTTP responses
        mockExternalApi.expect(requestTo(containsString("/api/config/expense-types")))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess(
                """
                {"expenseTypes": [{"code": "MEAL", "name": "Meals"}]}
                """,
                MediaType.APPLICATION_JSON
            ));

        // Act: trigger processing via the real HTTP endpoint
        ResponseEntity<String> response = restTemplate.postForEntity(
            "/reports/process",
            new ProcessingRequest("report-123", "company-abc"),
            String.class
        );

        // Assert
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        // Verify the expected database state
        Optional<Report> saved = reportRepository.findById("report-123");
        assertThat(saved).isPresent();
        assertThat(saved.get().getStatus()).isEqualTo(Status.PROCESSED);
        assertThat(saved.get().getAuditEntries()).hasSize(1);
    }
}

The test exercises the full request path: HTTP request → Spring controller → service layer → external API call (mocked) → MongoDB write (real). It asserts both the HTTP response and the resulting database state.

Selective Bean Overrides with @MockitoBean #

Sometimes you want the real application context but need to stub a specific bean — a notification sender, an external event publisher, a slow downstream client. @MockitoBean (introduced in Spring Boot 3.4 as a replacement for @MockBean) replaces the bean with a Mockito mock for the duration of the test:

class NotificationComponentTest extends BaseComponentTest {

    @MockitoBean
    private EmailNotificationService emailNotificationService;

    @Test
    void approvalTriggersNotification() {
        restTemplate.postForEntity(
            "/reports/approve/report-123",
            null,
            String.class
        );

        verify(emailNotificationService, times(1))
            .sendApprovalNotification(eq("report-123"), any());
    }
}

@MockitoBean creates a new Spring context for classes that use it — if multiple test classes use @MockitoBean with different sets of mocked beans, each creates its own context. Consolidate @MockitoBean declarations in the base class if you want a single shared context:

// In BaseComponentTest — mocks declared here are shared across all component tests
@MockitoBean
protected EmailNotificationService emailNotificationService;

With this, all component tests that extend BaseComponentTest share one application context, and emailNotificationService is always a mock. Use Mockito.reset(emailNotificationService) in @BeforeEach to clear recorded interactions between tests.

Verifying Call Counts #

A useful pattern for components that should make exactly N calls to an external API for a given input:

@Test
void configApiIsCalledOncePerCompany() {
    // Set up the mock to accept any number of calls
    mockExternalApi.expect(manyTimes(),
            requestTo(containsString("/api/config")))
        .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));

    // Process multiple reports for the same company
    restTemplate.postForEntity("/reports/process", new ProcessingRequest("r1", "company-abc"), String.class);
    restTemplate.postForEntity("/reports/process", new ProcessingRequest("r2", "company-abc"), String.class);

    // Verify the config API was called exactly once (cache hit on second call)
    mockExternalApi.verify(requestTo(containsString("/api/config")), times(1));
}

This pattern catches cache regressions: if the in-process cache is broken, the mock will record two calls and times(1) will fail.

Running Component Tests #

./gradlew componentTest

Component tests are slower than unit tests — Testcontainers adds 5–15 seconds for container startup on first run (cached on subsequent runs). Keep component tests focused on integration correctness rather than exhaustive logic coverage. Unit tests cover logic; component tests cover integration.

The shouldRunAfter test declaration in the Gradle task means ./gradlew check runs unit tests first, then component tests. If unit tests fail, you get fast feedback before waiting for the slower component tests.