Back to Blog
spring-boottestingjunittestcontainersjavatest-strategy

Spring Boot Testing Strategy: Unit, Integration, and E2E Tests Done Right (2026)

Most Spring Boot test suites are either too slow or too shallow. The right strategy uses fast unit tests for logic, Testcontainers for integration, and focused E2E tests for critical flows.

J

JOptimize Team

May 30, 2026· 10 min read

A common Spring Boot test suite has the same problem in two directions: either it's all unit tests with mocked everything — which passes but gives false confidence because the real interactions aren't tested — or it's all @SpringBootTest tests that start the entire application context for every test class, making the suite take 10 minutes and discouraging developers from running it.

The solution is a deliberate testing strategy that uses the right test type for each scenario.


The Testing Pyramid for Spring Boot

The classic testing pyramid applies to Spring Boot with specific tools at each level:

           E2E Tests (few, slow)
              API contract tests
         ─────────────────────────
        Integration Tests (medium)
         Testcontainers + JPA
         @WebMvcTest + MockMvc
       ──────────────────────────────
      Unit Tests (many, fast, isolated)
       Service logic, mappers, validators
       No Spring context, no database

The shape matters: lots of fast unit tests at the base, fewer integration tests in the middle, very few E2E tests at the top. Inverting this pyramid (many slow E2E tests, few unit tests) gives you slow feedback and hard-to-debug failures.


Layer 1: Pure Unit Tests

Unit tests for service logic, mappers, and validators run without Spring — no application context, no database, no Kafka. They're milliseconds fast:

// No @SpringBootTest — pure JUnit 5 + Mockito public class OrderServiceTest { @Mock private OrderRepository orderRepo; @Mock private InventoryService inventoryService; @Mock private PaymentService paymentService; @InjectMocks private OrderService orderService; @BeforeEach void setup() { MockitoAnnotations.openMocks(this); } @Test void placeOrder_whenSufficientStock_returnsConfirmedOrder() { // Arrange when(inventoryService.hasStock(1L, 2)).thenReturn(true); when(orderRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); PlaceOrderRequest req = new PlaceOrderRequest(1L, List.of(new OrderItem(1L, 2, BigDecimal.TEN))); // Act Order result = orderService.placeOrder(req); // Assert assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED); verify(orderRepo, times(1)).save(any(Order.class)); } @Test void placeOrder_whenInsufficientStock_throwsException() { when(inventoryService.hasStock(anyLong(), anyInt())).thenReturn(false); assertThatThrownBy(() -> orderService.placeOrder(buildRequest())) .isInstanceOf(InsufficientStockException.class) .hasMessageContaining("Insufficient stock"); verify(orderRepo, never()).save(any()); } }

These tests run in under 100ms total. Write as many as needed to cover all business logic branches.


Layer 2a: @WebMvcTest — Controller Slice Tests

@WebMvcTest boots only the web layer — controllers, filters, security. It doesn't start the service or repository layer:

@WebMvcTest(OrderController.class) @AutoConfigureMockMvc public class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean // Mocks the service — not started private OrderService orderService; @Autowired private ObjectMapper objectMapper; @Test @WithMockUser(roles = "USER") void getOrder_whenExists_returns200WithDto() throws Exception { OrderDto dto = new OrderDto(1L, OrderStatus.CONFIRMED, BigDecimal.TEN); when(orderService.findById(1L)).thenReturn(dto); mockMvc.perform(get("/api/v1/orders/1") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.status").value("CONFIRMED")); } @Test @WithMockUser(roles = "USER") void createOrder_whenInvalidBody_returns400WithErrors() throws Exception { String invalidBody = "{\"customerId\": null, \"items\": []}"; // Fails validation mockMvc.perform(post("/api/v1/orders") .contentType(MediaType.APPLICATION_JSON) .content(invalidBody)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.errors.customerId").exists()) .andExpect(jsonPath("$.errors.items").exists()); } @Test void getOrder_whenUnauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/orders/1")) .andExpect(status().isUnauthorized()); } }

@WebMvcTest is fast (1-2 seconds to start), tests validation, security config, and serialization without a database.


Layer 2b: Testcontainers — Real Database Tests

For repository and integration tests, use a real PostgreSQL instance:

@SpringBootTest @Testcontainers @Transactional // Rolls back after each test — clean state public class OrderRepositoryTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired private OrderRepository orderRepo; @Test void findByCustomerIdAndStatus_returnsOnlyMatchingOrders() { // Arrange: create test data directly in the real DB Order confirmed = orderRepo.save(buildOrder(1L, OrderStatus.CONFIRMED)); Order pending = orderRepo.save(buildOrder(1L, OrderStatus.PENDING)); Order otherUser = orderRepo.save(buildOrder(2L, OrderStatus.CONFIRMED)); // Act List<Order> result = orderRepo.findByCustomerIdAndStatus( 1L, OrderStatus.CONFIRMED, PageRequest.of(0, 10)).getContent(); // Assert assertThat(result).hasSize(1) .extracting(Order::getId) .containsExactly(confirmed.getId()); } @Test void findWithCustomer_doesNotTriggerNPlus1() { // Save test data for (int i = 0; i < 5; i++) orderRepo.save(buildOrderWithCustomer(i)); // Enable SQL count assertion SQLStatementCountValidator.reset(); List<Order> orders = orderRepo.findAllWithCustomer(); orders.forEach(o -> o.getCustomer().getName()); // Access lazy relation // Verify exactly 1 query (JOIN FETCH, not N+1) SQLStatementCountValidator.assertSelectCount(1); } }

Layer 2c: Full Spring Context Integration Test

For testing complete flows through multiple layers:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers public class OrderFlowIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Container @ServiceConnection static KafkaContainer kafka = new KafkaContainer("confluentinc/cp-kafka:7.6.0"); @Autowired private TestRestTemplate restTemplate; @Autowired private OrderRepository orderRepo; @Test void completePurchaseFlow_savesOrderAndPublishesEvent() throws Exception { PlaceOrderRequest req = new PlaceOrderRequest( 1L, List.of(new OrderItem(1L, 2, BigDecimal.TEN))); ResponseEntity<OrderDto> response = restTemplate.postForEntity( "/api/v1/orders", req, OrderDto.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(orderRepo.findById(response.getBody().id())).isPresent(); // Verify Kafka event was published... } }

Test Performance: Keep CI Under 5 Minutes

<!-- Separate fast and slow tests with Maven profiles --> <profiles> <profile> <id>fast</id> <build><plugins><plugin> <artifactId>maven-surefire-plugin</artifactId> <configuration> <excludes> <exclude>**/*IntegrationTest.java</exclude> <exclude>**/*IT.java</exclude> </excludes> </configuration> </plugin></plugins></build> </profile> <profile> <id>integration</id> <!-- Includes integration tests, requires Docker --> </profile> </profiles>

Developers run mvn test -P fast locally (< 30 seconds). CI runs the full suite (3-5 minutes with Testcontainers container reuse).


Common Mistakes to Avoid

  • @SpringBootTest for every test — boots the full context (2-5 seconds) even for testing a single service method; use plain JUnit for logic tests
  • Mocking the repository in service tests — tells you the code compiles, not that it works; use Testcontainers to test the real query
  • Not testing security — add @WithMockUser and test that protected endpoints return 401/403 for unauthorized access
  • Shared mutable state in tests — tests that depend on each other are fragile; use @Transactional or @DirtiesContext to isolate state

Summary

An effective Spring Boot testing strategy uses three layers: pure JUnit unit tests for business logic (fast, many), @WebMvcTest for controller/security/validation tests, and Testcontainers for real database integration tests. The full suite should run in under 5 minutes. Fast feedback from the unit layer keeps developers in the flow; the integration layer catches the bugs that unit tests can't.


Test for Performance, Not Just Correctness

JOptimize adds a complementary layer — static analysis that catches N+1 queries and missing indexes that your tests won't catch until production scale.

Test correctly. Perform correctly.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.