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

· Updated May 9, 2026

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.

Is your AI API down right now? Check live status for OpenAI, Claude, Gemini, Groq, and more.
Check status →

Frequently Asked Questions

What is the difference between @WebMvcTest and @SpringBootTest?
@WebMvcTest loads only the web layer (controllers, filters, advice) making tests fast. @SpringBootTest loads the entire application context and is better for full integration tests.
Should I use H2 or Testcontainers for database tests?
Use Testcontainers. H2 behaves differently from Postgres/MySQL in areas like JSON columns, window functions, and locking. Testcontainers runs the real database in Docker so you catch production bugs.
How do I speed up slow Spring Boot tests?
Use test slices (@WebMvcTest, @DataJpaTest) instead of @SpringBootTest, share application context across tests, and use @MockBean sparingly since each unique mock combination creates a new context.
What is @DataJpaTest used for?
@DataJpaTest configures only JPA components (entities, repositories) with an embedded or test database. It auto-rolls back transactions after each test, making repository tests fast and isolated.
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.