Most Spring Boot test suites are either too slow (too many @SpringBootTest) or too shallow (too many mocked unit tests that don't catch real bugs). Learn the right balance.
JOptimize Team
A Spring Boot test suite that takes 8 minutes to run doesn't get run. Developers skip it, CI becomes a bottleneck, and confidence in the tests erodes. The goal is a suite that runs in under 2 minutes, catches real bugs, and gives you confidence to deploy.
/‾‾‾‾‾‾‾‾‾‾‾‾\
/ E2E (1-5%) \ Few, slow, high value
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Integration (25%)\ @SpringBootTest, Testcontainers
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Unit Tests (70%) \ Fast, focused, no Spring context
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
// No Spring annotations — pure JUnit 5 + Mockito class OrderServiceTest { @Mock OrderRepository orderRepo; @Mock PaymentService paymentService; @InjectMocks OrderService orderService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test void createOrder_withValidRequest_savesAndReturnsOrder() { // Arrange CreateOrderRequest req = new CreateOrderRequest(1L, List.of(new OrderItem(42L, 2))); Order savedOrder = new Order(100L, req.customerId(), OrderStatus.PENDING); when(orderRepo.save(any())).thenReturn(savedOrder); // Act OrderDto result = orderService.createOrder(req); // Assert assertThat(result.id()).isEqualTo(100L); assertThat(result.status()).isEqualTo("PENDING"); verify(orderRepo).save(any(Order.class)); verifyNoInteractions(paymentService); // Payment not called at creation } @Test void createOrder_withEmptyItems_throwsValidationException() { CreateOrderRequest req = new CreateOrderRequest(1L, List.of()); assertThatThrownBy(() -> orderService.createOrder(req)) .isInstanceOf(ValidationException.class) .hasMessageContaining("items"); } }
Runs in < 50ms. Tests business logic in isolation. Use for: services, domain logic, utility classes.
@WebMvcTest(OrderController.class) // Only loads web layer — no DB, no full context class OrderControllerTest { @Autowired MockMvc mockMvc; @MockBean OrderService orderService; // Mock the service @Autowired ObjectMapper objectMapper; @Test void getOrder_whenExists_returns200WithOrder() throws Exception { OrderDto dto = new OrderDto(42L, "PENDING", new BigDecimal("150.00")); when(orderService.findById(42L)).thenReturn(dto); mockMvc.perform(get("/api/v1/orders/42") .header("Authorization", "Bearer " + testJwt())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(42)) .andExpect(jsonPath("$.status").value("PENDING")) .andExpect(jsonPath("$.total").value(150.00)); } @Test void getOrder_whenNotFound_returns404() throws Exception { when(orderService.findById(99L)).thenThrow(new OrderNotFoundException(99L)); mockMvc.perform(get("/api/v1/orders/99") .header("Authorization", "Bearer " + testJwt())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("Order 99 not found")); } }
Runs in ~500ms (loads only web layer). Tests: request mapping, validation, serialization, error handling, security rules.
@DataJpaTest // Loads JPA layer only — in-memory H2 by default class OrderRepositoryTest { @Autowired OrderRepository orderRepository; @Autowired TestEntityManager em; @Test void findByCustomerId_returnsOnlyCustomerOrders() { // Arrange — insert test data em.persist(new Order(null, 1L, OrderStatus.PENDING, new BigDecimal("100")); em.persist(new Order(null, 1L, OrderStatus.SHIPPED, new BigDecimal("200")); em.persist(new Order(null, 2L, OrderStatus.PENDING, new BigDecimal("50")); em.flush(); // Act List<Order> orders = orderRepository.findByCustomerId(1L); // Assert assertThat(orders).hasSize(2) .extracting(Order::getCustomerId) .containsOnly(1L); } @Test void findPendingOrdersOlderThan_returnsExpired() { // Test your @Query with real SQL — catches JPQL typos LocalDateTime threshold = LocalDateTime.now().minusDays(7); List<Order> expired = orderRepository.findPendingOrdersOlderThan(threshold); assertThat(expired).allMatch(o -> o.getCreatedAt().isBefore(threshold)); } }
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers class OrderIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16") .withDatabaseName("testdb"); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired TestRestTemplate restTemplate; @Test void createAndRetrieveOrder_fullFlow() { // Create order CreateOrderRequest req = new CreateOrderRequest(1L, List.of(new OrderItem(42L, 3))); ResponseEntity<OrderDto> createResp = restTemplate.postForEntity( "/api/v1/orders", req, OrderDto.class); assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.CREATED); Long orderId = createResp.getBody().id(); // Retrieve it ResponseEntity<OrderDto> getResp = restTemplate.getForEntity( "/api/v1/orders/" + orderId, OrderDto.class); assertThat(getResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(getResp.getBody().id()).isEqualTo(orderId); } }
Runs in ~10-30 seconds (container startup). Tests the complete stack: HTTP → controller → service → repository → real PostgreSQL.
// Reuse one container across all test classes — major speedup @Testcontainers public abstract class AbstractIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16") .withReuse(true); // Reuse container across test runs (add .testcontainers.properties) @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } } // All integration tests extend this class OrderIntegrationTest extends AbstractIntegrationTest { ... } class UserIntegrationTest extends AbstractIntegrationTest { ... } // One container started, shared across all — saves 20s per test class
@SpringBootTest for everything — loading the full context takes 10-30s; use @WebMvcTest, @DataJpaTest, or plain unit tests for focused testsCREATE INDEX CONCURRENTLY fails on H2; use Testcontainers for real DB behavior@Transactional on integration tests rolls back after each test, keeping the DB clean automaticallyA fast, reliable Spring Boot test suite uses three layers: plain unit tests with Mockito for business logic (< 50ms each), @WebMvcTest for controller behavior (< 500ms), and @SpringBootTest + Testcontainers for end-to-end flows (10-30s). The critical optimization is avoiding @SpringBootTest for tests that don't need the full stack. With shared Testcontainers and @DataJpaTest, you can keep total suite time under 2 minutes for a medium-sized application.
JOptimize analyzes your test suite for untested service methods, missing error path coverage, and over-use of @SpringBootTest that slows your CI pipeline.
Write tests that actually catch bugs — free coverage scan.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.