Skip to content
All posts

Testing Spring Boot: Slices, Testcontainers and JaCoCo

By 9 min read

A test suite that takes ten minutes gets skipped. One that mocks everything passes while production breaks. The useful middle is a mix: many fast unit and slice tests, a real database wherever SQL matters, a handful of full-application tests, and a coverage gate so none of it quietly erodes.

This guide builds that mix for a Spring Boot 3 service with Gradle, JUnit 5, Testcontainers and JaCoCo. The coverage setup comes from my EdcOnboardingApplication repository.

Pick the smallest test that proves the point

Spring Boot’s test slices start only part of the application, which makes them much faster than a full @SpringBootTest. Choosing the right one is most of the work:

Matrix of Spring Boot test types: a unit test runs a real service with a mocked repository, @WebMvcTest loads controllers with mocked services, @DataJpaTest loads repositories with a real PostgreSQL, @SpringBootTest loads everything
What each kind of test loads. The higher the row, the faster the test.

Unit tests: no Spring at all

Business rules in a service need no application context. With constructor injection, Mockito can build the service directly:

OrderServiceTest.java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository orderRepository;
    @Mock ProductService productService;
    @Mock ApplicationEventPublisher events;
    @InjectMocks OrderService orderService;

    @Test
    void rejectsOrderWhenThePriceHasChanged() {
        when(productService.getByCode("P100")).thenReturn(Optional.of(
                new Product("P100", "The Hunger Games", "", "", new BigDecimal("40.00"))));

        var request = anOrderFor("P100", new BigDecimal("34.00"));

        assertThatThrownBy(() -> orderService.createOrder(request))
                .isInstanceOf(InvalidOrderException.class)
                .hasMessage("Product price mismatch");
        verifyNoInteractions(orderRepository, events);
    }
}

These run in milliseconds, so this is where most of your test cases should live.

Web slice: @WebMvcTest

@WebMvcTest loads controllers, controller advice, Jackson and the MVC infrastructure, but no services or repositories. That makes it the right place to test request mapping, validation, JSON shape and error responses:

OrderControllerTest.java
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService;

    @Test
    void listsOrders() throws Exception {
        when(orderService.findOrders())
                .thenReturn(List.of(new OrderSummary("ORD-1", OrderStatus.NEW)));

        mockMvc.perform(get("/api/orders"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].orderNumber").value("ORD-1"));
    }
}

@MockitoBean replaces Boot’s @MockBean, which is deprecated since Spring Boot 3.4. For a full example of testing error responses, see Spring Boot REST Errors with ProblemDetail and Validation.

A real PostgreSQL with Testcontainers

Testing repositories against H2 tests H2. PostgreSQL-specific SQL such as ON CONFLICT, jsonb columns or array types either fails or behaves differently, and Flyway migrations written for PostgreSQL may not run at all. Testcontainers starts the real database in Docker for the duration of the tests:

build.gradle
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'

Spring Boot manages the versions. Since Boot 3.1, @ServiceConnection reads the container’s host, port and credentials and configures the DataSource from them, so there is no @DynamicPropertySource boilerplate:

OrderRepositoryTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository orderRepository;

    @Test
    void findsAnOrderByItsNumber() {
        orderRepository.save(anOrder("ORD-1"));

        assertThat(orderRepository.findByOrderNumber("ORD-1")).isPresent();
    }
}

@DataJpaTest runs your Flyway or Liquibase migrations, wraps each test in a transaction and rolls it back afterwards. Replace.NONE stops it from swapping the container for an embedded database.

Share one container across the suite

Declaring the container as a bean lets Spring manage it, and every test that imports the same configuration reuses it:

TestcontainersConfig.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

The same class gives you a local development database with no installation. Start the application from a small class in src/test:

TestShopApplication.java
public class TestShopApplication {

    public static void main(String[] args) {
        SpringApplication.from(ShopApplication::main)
                .with(TestcontainersConfig.class)
                .run(args);
    }
}

A few full-application tests

Keep @SpringBootTest for the paths that cross every layer: an order placed over HTTP, saved in PostgreSQL, with its event published. A handful of these catch wiring mistakes that no slice can:

PlaceOrderIT.java
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class PlaceOrderIT {

    @Autowired MockMvc mockMvc;
    @Autowired OrderRepository orderRepository;

    @Test
    void placesAnOrderEndToEnd() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(validOrderJson()))
                .andExpect(status().isCreated());

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

A coverage gate with JaCoCo

JaCoCo measures which lines and branches the tests executed. The Gradle plugin produces a report after every test run:

build.gradle
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.12"
}

test {
    useJUnitPlatform()
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        html.required = true
        xml.required = true    // for CI tools and code-review bots
    }
}

The HTML report lands in build/reports/jacoco/test/html/index.html. A report on its own changes nothing, though. A verification rule makes the build fail when coverage drops:

build.gradle
jacocoTestCoverageVerification {
    dependsOn test
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
        }
    }
}

check.dependsOn jacocoTestCoverageVerification

Now ./gradlew check, and therefore CI, fails below 80% line coverage. Pick the threshold from where the project is today and raise it over time. Exclude generated code and plain configuration classes, so the number reflects code that has behaviour.

Treat the gate as a ratchet, not a goal. It stops coverage from sliding; it says nothing about whether the assertions are any good. A service tested only for its happy path can still score 90%.

On Spring Boot 4 and Testcontainers 2

Spring Boot 4 moves to Testcontainers 2, which prefixes every module with testcontainers- (for example org.testcontainers:testcontainers-postgresql) and moves container classes into per-module packages, such as org.testcontainers.postgresql.PostgreSQLContainer. The approach above, with slices, @ServiceConnection and a shared container configuration, stays the same.

Checklist

  • Plain JUnit and Mockito for business rules: most of your tests.
  • @WebMvcTest for mapping, validation and error responses.
  • @DataJpaTest against a real PostgreSQL with Testcontainers and @ServiceConnection.
  • A few @SpringBootTest end-to-end paths, sharing one context.
  • One container configuration, reused by tests and local development.
  • A JaCoCo report plus a verification rule wired into check.