Spring Boot 6 min read

Spring Boot Testing: Unit to Integration Tests That Actually Work

Test Spring Boot apps properly. Unit tests, @WebMvcTest, @DataJpaTest, Testcontainers, and test slices explained with real examples.

MR

Moshiour Rahman

Advertisement

The Problem: Your Tests Are Lying to You

You have 90% test coverage. CI is green. Then production breaks.

Why? Your tests:

  • Mock everything → don’t catch integration issues
  • Use H2 → misses Postgres-specific behavior
  • Test implementation → break on refactoring
  • Are slow → developers skip running them

Good tests catch bugs before production. Here’s how to write them.

Quick Answer (TL;DR)

// Unit test - fast, isolated
@Test
void shouldCalculateTotal() {
    var service = new OrderService(mock(OrderRepository.class));
    assertEquals(100, service.calculateTotal(order));
}

// Integration test - real database
@DataJpaTest
@Testcontainers
class OrderRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Test
    void shouldFindByStatus() {
        var orders = repository.findByStatus(PENDING);
        assertThat(orders).hasSize(2);
    }
}

// Web test - HTTP layer only
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Test
    void shouldReturn404WhenNotFound() {
        mockMvc.perform(get("/orders/999"))
            .andExpect(status().isNotFound());
    }
}

The Testing Pyramid

Testing Pyramid

LayerSpeedScopeTools
Unit~1msSingle classJUnit, Mockito
Integration~100msMultiple classes + DB@DataJpaTest, Testcontainers
E2E~1s+Full application@SpringBootTest, MockMvc

Unit Tests: Fast and Focused

Service Layer

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentService paymentService;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCreateOrder() {
        // Given
        var request = new CreateOrderRequest("user-123", List.of(
            new OrderItem("SKU-1", 2),
            new OrderItem("SKU-2", 1)
        ));
        when(orderRepository.save(any())).thenAnswer(inv -> {
            Order order = inv.getArgument(0);
            order.setId("order-456");
            return order;
        });

        // When
        Order result = orderService.createOrder(request);

        // Then
        assertThat(result.getId()).isEqualTo("order-456");
        assertThat(result.getItems()).hasSize(2);
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void shouldThrowWhenPaymentFails() {
        // Given
        var order = new Order("order-123", List.of());
        when(paymentService.charge(any())).thenThrow(new PaymentException("Declined"));

        // When/Then
        assertThrows(PaymentException.class,
            () -> orderService.processPayment(order));

        verify(orderRepository, never()).save(any());
    }
}

Testing Edge Cases

@ParameterizedTest
@CsvSource({
    "0, 0, 0",
    "100, 0, 100",
    "100, 10, 90",
    "100, 100, 0"
})
void shouldCalculateDiscount(int price, int discountPercent, int expected) {
    var calculator = new PriceCalculator();
    assertEquals(expected, calculator.applyDiscount(price, discountPercent));
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"  ", "\t", "\n"})
void shouldRejectBlankInput(String input) {
    assertThrows(IllegalArgumentException.class,
        () -> validator.validate(input));
}

@WebMvcTest: Controller Layer Only

Tests HTTP handling without starting full application.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrder() throws Exception {
        // Given
        var order = new Order("order-123", "user-1", List.of());
        when(orderService.findById("order-123")).thenReturn(Optional.of(order));

        // When/Then
        mockMvc.perform(get("/api/orders/order-123"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("order-123"))
            .andExpect(jsonPath("$.userId").value("user-1"));
    }

    @Test
    void shouldReturn404WhenNotFound() throws Exception {
        when(orderService.findById("missing")).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/missing"))
            .andExpect(status().isNotFound());
    }

    @Test
    void shouldValidateRequest() throws Exception {
        var invalidRequest = """
            {
                "userId": "",
                "items": []
            }
            """;

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidRequest))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }

    @Test
    void shouldCreateOrder() throws Exception {
        var request = """
            {
                "userId": "user-123",
                "items": [{"sku": "SKU-1", "quantity": 2}]
            }
            """;
        var created = new Order("order-456", "user-123", List.of());
        when(orderService.createOrder(any())).thenReturn(created);

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(request))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/orders/order-456"));
    }
}

@DataJpaTest: Repository Layer

Tests JPA repositories with real database operations.

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

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

    @Autowired
    private OrderRepository repository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindByUserId() {
        // Given
        var order1 = entityManager.persist(new Order(null, "user-1", PENDING));
        var order2 = entityManager.persist(new Order(null, "user-1", SHIPPED));
        entityManager.persist(new Order(null, "user-2", PENDING));
        entityManager.flush();

        // When
        var orders = repository.findByUserId("user-1");

        // Then
        assertThat(orders).hasSize(2)
            .extracting(Order::getId)
            .containsExactlyInAnyOrder(order1.getId(), order2.getId());
    }

    @Test
    void shouldFindPendingOrdersOlderThan() {
        // Given
        var oldOrder = new Order(null, "user-1", PENDING);
        oldOrder.setCreatedAt(LocalDateTime.now().minusDays(7));
        entityManager.persist(oldOrder);

        var newOrder = new Order(null, "user-1", PENDING);
        newOrder.setCreatedAt(LocalDateTime.now());
        entityManager.persist(newOrder);
        entityManager.flush();

        // When
        var staleOrders = repository.findPendingOrdersOlderThan(
            LocalDateTime.now().minusDays(3)
        );

        // Then
        assertThat(staleOrders).hasSize(1)
            .extracting(Order::getId)
            .containsOnly(oldOrder.getId());
    }
}

Testcontainers: Real Databases

@SpringBootTest
@Testcontainers
class IntegrationTest {

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

    @Container
    @ServiceConnection
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
    );

    @DynamicPropertySource
    static void kafkaProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Test
    void shouldProcessOrderEndToEnd() {
        // Full integration test with real Postgres, Redis, Kafka
    }
}

Reusable Container Configuration

// src/test/java/io/techyowls/TestcontainersConfig.java
@TestConfiguration
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);  // Reuse across tests
    }

    @Bean
    @ServiceConnection
    GenericContainer<?> redisContainer() {
        return new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);
    }
}
// Use in tests
@SpringBootTest
@Import(TestcontainersConfig.class)
class OrderServiceIntegrationTest {
    // Containers are injected and reused
}

@SpringBootTest: Full Application

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void setUp() {
        orderRepository.deleteAll();
    }

    @Test
    void shouldCreateAndRetrieveOrder() {
        // Create
        var request = new CreateOrderRequest("user-123", List.of(
            new OrderItem("SKU-1", 2)
        ));

        var createResponse = restTemplate.postForEntity(
            "/api/orders", request, Order.class
        );

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        var createdOrder = createResponse.getBody();
        assertThat(createdOrder.getId()).isNotNull();

        // Retrieve
        var getResponse = restTemplate.getForEntity(
            "/api/orders/{id}", Order.class, createdOrder.getId()
        );

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getUserId()).isEqualTo("user-123");
    }

    @Test
    void shouldReturnPaginatedResults() {
        // Given - 25 orders
        IntStream.range(0, 25).forEach(i ->
            orderRepository.save(new Order(null, "user-" + i, PENDING))
        );

        // When
        var response = restTemplate.getForEntity(
            "/api/orders?page=0&size=10", PagedResponse.class
        );

        // Then
        assertThat(response.getBody().getContent()).hasSize(10);
        assertThat(response.getBody().getTotalElements()).isEqualTo(25);
        assertThat(response.getBody().getTotalPages()).isEqualTo(3);
    }
}

Test Slices Reference

AnnotationWhat It LoadsUse For
@WebMvcTestControllers, filters, convertersREST API tests
@DataJpaTestJPA repositories, EntityManagerDatabase tests
@DataMongoTestMongoDB repositoriesMongoDB tests
@JsonTestJackson ObjectMapperJSON serialization
@WebFluxTestWebFlux controllersReactive endpoints
@JdbcTestJdbcTemplate, DataSourceRaw JDBC tests

Testing Security

@WebMvcTest(SecureController.class)
@Import(SecurityConfig.class)
class SecureControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectUnauthenticatedRequest() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void shouldRejectUnauthorizedUser() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void shouldAllowAdmin() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isOk());
    }

    @Test
    void shouldAuthenticateWithJwt() throws Exception {
        var token = jwtService.generateToken("admin", Set.of("ROLE_ADMIN"));

        mockMvc.perform(get("/api/admin/users")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }
}

Test Configuration

application-test.yml

spring:
  datasource:
    url: jdbc:tc:postgresql:16-alpine:///testdb
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

logging:
  level:
    org.springframework.test: DEBUG
    org.testcontainers: INFO

Faster Tests

// Parallel execution
// src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent

Common Pitfalls

PitfallProblemFix
Testing implementationTests break on refactorTest behavior, not internals
H2 in testsMisses DB-specific bugsUse Testcontainers
No test isolationTests affect each other@BeforeEach cleanup, @Transactional
Slow full testsDevelopers skip themUse test slices
Mocking too muchFalse confidenceIntegration tests for critical paths

Code Repository

Complete test examples:

GitHub: techyowls/techyowls-io-blog-public/spring-boot-testing

git clone https://github.com/techyowls/techyowls-io-blog-public.git
cd techyowls-io-blog-public/spring-boot-testing
./mvnw test

Further Reading


Write tests that catch bugs, not tests that pass. Follow TechyOwls for more practical guides.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.