Back to Blog
spring-boottestingjunit5testcontainersjavatdd

Spring Boot Testing: Unit vs Integration Tests — What to Write and When (2026)

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.

J

JOptimize Team

May 28, 2026· 10 min read

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.


The Test Pyramid for Spring Boot

          /‾‾‾‾‾‾‾‾‾‾‾‾\
         / E2E (1-5%)   \      Few, slow, high value
        /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
       / Integration (25%)\    @SpringBootTest, Testcontainers
      /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
     /   Unit Tests (70%)   \  Fast, focused, no Spring context
    /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\

Unit Tests: Fast, 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: Test Your Controllers Without Full Context

@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: Test Repositories With Real SQL

@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 + Testcontainers: Real Integration

@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.


Shared Testcontainers (Speed Optimization)

// 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

Common Mistakes to Avoid

  • @SpringBootTest for everything — loading the full context takes 10-30s; use @WebMvcTest, @DataJpaTest, or plain unit tests for focused tests
  • Mocking everything in unit tests — if you mock all dependencies, you test nothing; mock external services (HTTP, email), not your own service layer
  • H2 in-memory DB for repository tests — H2 has different SQL dialect than PostgreSQL; CREATE INDEX CONCURRENTLY fails on H2; use Testcontainers for real DB behavior
  • Not resetting state between tests@Transactional on integration tests rolls back after each test, keeping the DB clean automatically

Summary

A 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.


Detect Test Coverage Gaps

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.

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.