Testing Spring Boot: Slices, Testcontainers and JaCoCo
By Uddip Acharjee Shuvo9 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:
Unit tests: no Spring at all
Business rules in a service need no application context. With constructor injection, Mockito can build the service directly:
@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:
@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:
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:
@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:
@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:
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:
@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:
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:
jacocoTestCoverageVerification {
dependsOn test
violationRules {
rule {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.80
}
}
}
}
check.dependsOn jacocoTestCoverageVerificationNow ./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.
@WebMvcTestfor mapping, validation and error responses.@DataJpaTestagainst a real PostgreSQL with Testcontainers and@ServiceConnection.- A few
@SpringBootTestend-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.