Skip to content
Unverified — AI-generated content. Help verify this page

Testing

Testing Spring Boot applications is fundamentally different from testing plain Java classes. You are not just testing business logic — you are testing how your code interacts with the Spring container, database, HTTP layer, security, and external services. Spring Boot provides test slices that load only the parts of the context you need, making tests fast and focused.

This page covers the complete testing toolkit: unit tests, slice tests, integration tests, Testcontainers, WireMock, and the strategies that keep your test suite fast and reliable.

Testing Pyramid

LevelSpeedSpring ContextDatabaseExternal Services
Unit~1msNoneNoneNone (mocked)
@WebMvcTest~500msWeb slice onlyNoneMocked (@MockBean)
@DataJpaTest~2sJPA slice onlyH2/TestcontainersNone
@SpringBootTest~5-10sFull contextTestcontainersWireMock

Unit Tests (No Spring)

The fastest tests. No Spring context, no DI container — just plain Java objects with mocks.

java
class OrderServiceTest {

    private OrderService orderService;

    // Manual mocks — no @MockBean needed
    private OrderRepository orderRepository = mock(OrderRepository.class);
    private ProductServiceClient productClient = mock(ProductServiceClient.class);
    private PaymentGateway paymentGateway = mock(PaymentGateway.class);
    private ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);

    @BeforeEach
    void setUp() {
        orderService = new OrderService(
                orderRepository, productClient, paymentGateway, eventPublisher);
    }

    @Test
    void placeOrder_ValidRequest_CreatesOrderAndPublishesEvent() {
        // Given
        UUID productId = UUID.randomUUID();
        var request = new CreateOrderRequest(
                UUID.randomUUID(), productId, 2, "CREDIT_CARD");

        var product = new ProductResponse(productId, "Widget", null,
                new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                100, true, List.of(), Instant.now(), Instant.now());

        when(productClient.getProduct(productId)).thenReturn(product);
        when(paymentGateway.charge(any(), any()))
                .thenReturn(new PaymentResult("PAY-123", "SUCCESS"));
        when(orderRepository.save(any(Order.class)))
                .thenAnswer(inv -> {
                    Order order = inv.getArgument(0);
                    ReflectionTestUtils.setField(order, "id", UUID.randomUUID());
                    return order;
                });

        // When
        OrderResponse result = orderService.placeOrder(request);

        // Then
        assertThat(result).isNotNull();
        assertThat(result.total()).isEqualByComparingTo(new BigDecimal("19.98"));

        verify(orderRepository).save(argThat(order ->
                order.getQuantity() == 2
                        && order.getStatus() == OrderStatus.CONFIRMED));
        verify(eventPublisher).publishEvent(any(OrderPlacedEvent.class));
        verify(paymentGateway).charge(eq("CREDIT_CARD"), eq(new BigDecimal("19.98")));
    }

    @Test
    void placeOrder_InsufficientStock_ThrowsException() {
        // Given
        UUID productId = UUID.randomUUID();
        var request = new CreateOrderRequest(
                UUID.randomUUID(), productId, 50, "CREDIT_CARD");

        var product = new ProductResponse(productId, "Widget", null,
                new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                5, true, List.of(), Instant.now(), Instant.now()); // Only 5 in stock

        when(productClient.getProduct(productId)).thenReturn(product);

        // When/Then
        assertThatThrownBy(() -> orderService.placeOrder(request))
                .isInstanceOf(BusinessRuleViolationException.class)
                .hasMessageContaining("Insufficient stock");

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

    @Test
    void placeOrder_PaymentFails_ThrowsExternalServiceException() {
        // Given
        UUID productId = UUID.randomUUID();
        var request = new CreateOrderRequest(
                UUID.randomUUID(), productId, 1, "CREDIT_CARD");

        var product = new ProductResponse(productId, "Widget", null,
                new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                100, true, List.of(), Instant.now(), Instant.now());

        when(productClient.getProduct(productId)).thenReturn(product);
        when(paymentGateway.charge(any(), any()))
                .thenThrow(new RuntimeException("Gateway timeout"));

        // When/Then
        assertThatThrownBy(() -> orderService.placeOrder(request))
                .isInstanceOf(ExternalServiceException.class)
                .hasMessageContaining("PaymentGateway");
    }
}

@WebMvcTest (Controller Slice)

Tests the web layer in isolation. Only loads controllers, filters, and web-related beans.

java
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private ProductService productService;

    @Test
    void listProducts_ReturnsPagedResponse() throws Exception {
        // Given
        var products = List.of(
                new ProductResponse(UUID.randomUUID(), "Widget", "A widget",
                        new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                        100, true, List.of(), Instant.now(), Instant.now()));

        Page<ProductResponse> page = new PageImpl<>(products,
                PageRequest.of(0, 20), 1);

        given(productService.findAll(any(Pageable.class))).willReturn(page);

        // When/Then
        mockMvc.perform(get("/api/v1/products")
                        .param("page", "0")
                        .param("size", "20"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content.length()").value(1))
                .andExpect(jsonPath("$.content[0].name").value("Widget"))
                .andExpect(jsonPath("$.content[0].price").value(9.99))
                .andExpect(jsonPath("$.totalElements").value(1));
    }

    @Test
    void createProduct_ValidBody_Returns201() throws Exception {
        // Given
        var request = new CreateProductRequest("Widget", "A widget",
                new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                100, List.of());

        var response = new ProductResponse(UUID.randomUUID(), "Widget", "A widget",
                new BigDecimal("9.99"), "WDG-001", ProductCategory.ELECTRONICS,
                100, true, List.of(), Instant.now(), Instant.now());

        given(productService.create(any())).willReturn(response);

        // When/Then
        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(header().exists("Location"))
                .andExpect(jsonPath("$.name").value("Widget"));
    }

    @Test
    void createProduct_InvalidBody_Returns400WithFieldErrors() throws Exception {
        // Given — invalid: blank name, negative price, no SKU
        String body = """
                {
                    "name": "",
                    "price": -5,
                    "category": "ELECTRONICS",
                    "stockQuantity": 100
                }
                """;

        // When/Then
        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.fieldErrors").isArray())
                .andExpect(jsonPath("$.fieldErrors[?(@.field == 'name')]").exists())
                .andExpect(jsonPath("$.fieldErrors[?(@.field == 'price')]").exists())
                .andExpect(jsonPath("$.fieldErrors[?(@.field == 'sku')]").exists());
    }

    @Test
    void getProduct_NotFound_Returns404() throws Exception {
        UUID id = UUID.randomUUID();
        given(productService.findById(id))
                .willThrow(new ResourceNotFoundException("Product", id));

        mockMvc.perform(get("/api/v1/products/{id}", id))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.errorCode").value("PRODUCT_NOT_FOUND"));
    }
}

@DataJpaTest (Repository Slice)

Tests JPA repositories with a real database. Uses H2 by default, but Testcontainers is recommended.

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

    @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
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void findByCategoryAndActiveTrue_ReturnsOnlyActiveProducts() {
        // Given
        Product active = Product.builder()
                .name("Active Widget")
                .price(new BigDecimal("9.99"))
                .sku("WDG-001")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(100)
                .active(true)
                .build();

        Product inactive = Product.builder()
                .name("Inactive Widget")
                .price(new BigDecimal("4.99"))
                .sku("WDG-002")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(0)
                .active(false)
                .build();

        Product otherCategory = Product.builder()
                .name("Book")
                .price(new BigDecimal("19.99"))
                .sku("BK-001")
                .category(ProductCategory.BOOKS)
                .stockQuantity(50)
                .active(true)
                .build();

        entityManager.persist(active);
        entityManager.persist(inactive);
        entityManager.persist(otherCategory);
        entityManager.flush();

        // When
        List<Product> results = productRepository
                .findByCategoryAndActiveTrue(ProductCategory.ELECTRONICS);

        // Then
        assertThat(results)
                .hasSize(1)
                .extracting(Product::getName)
                .containsExactly("Active Widget");
    }

    @Test
    void findBySku_ExistingSku_ReturnsProduct() {
        Product product = Product.builder()
                .name("Widget")
                .price(new BigDecimal("9.99"))
                .sku("UNIQUE-SKU")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(10)
                .active(true)
                .build();

        entityManager.persist(product);
        entityManager.flush();

        Optional<Product> found = productRepository.findBySku("UNIQUE-SKU");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Widget");
    }

    @Test
    void save_DuplicateSku_ThrowsException() {
        Product first = Product.builder()
                .name("First")
                .price(new BigDecimal("9.99"))
                .sku("SAME-SKU")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(10)
                .active(true)
                .build();

        Product duplicate = Product.builder()
                .name("Duplicate")
                .price(new BigDecimal("19.99"))
                .sku("SAME-SKU")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(5)
                .active(true)
                .build();

        entityManager.persist(first);
        entityManager.flush();

        assertThatThrownBy(() -> {
            entityManager.persist(duplicate);
            entityManager.flush();
        }).isInstanceOf(PersistenceException.class);
    }
}

@SpringBootTest (Full Integration)

Loads the complete application context. Use for end-to-end tests.

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class OrderIntegrationTest {

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

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void fullOrderWorkflow() {
        // 1. Create a product
        Product product = productRepository.save(Product.builder()
                .name("Test Widget")
                .price(new BigDecimal("29.99"))
                .sku("TST-001")
                .category(ProductCategory.ELECTRONICS)
                .stockQuantity(100)
                .active(true)
                .build());

        // 2. Fetch the product via API
        ResponseEntity<ProductResponse> getResponse = restTemplate.getForEntity(
                "/api/v1/products/{id}", ProductResponse.class, product.getId());

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody()).isNotNull();
        assertThat(getResponse.getBody().name()).isEqualTo("Test Widget");

        // 3. List products
        ResponseEntity<String> listResponse = restTemplate.getForEntity(
                "/api/v1/products", String.class);
        assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

WireMock (External Service Mocking)

xml
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.5.4</version>
    <scope>test</scope>
</dependency>
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
class PaymentServiceClientTest {

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("app.payment.base-url", () -> "http://localhost:8089");
    }

    @Autowired
    private PaymentServiceClient paymentClient;

    @Test
    void chargePayment_Success() {
        // Given
        stubFor(post(urlEqualTo("/v1/charges"))
                .withRequestBody(matchingJsonPath("$.amount", equalTo("29.99")))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("""
                                {
                                    "id": "ch_123",
                                    "status": "succeeded",
                                    "amount": 29.99
                                }
                                """)));

        // When
        PaymentResult result = paymentClient.charge(
                new BigDecimal("29.99"), "tok_visa");

        // Then
        assertThat(result.status()).isEqualTo("succeeded");
        verify(postRequestedFor(urlEqualTo("/v1/charges")));
    }

    @Test
    void chargePayment_Timeout_ThrowsException() {
        stubFor(post(urlEqualTo("/v1/charges"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withFixedDelay(5000))); // 5 second delay

        assertThatThrownBy(() ->
                paymentClient.charge(new BigDecimal("29.99"), "tok_visa"))
                .isInstanceOf(ExternalServiceException.class);
    }

    @Test
    void chargePayment_ServerError_RetriesAndFails() {
        stubFor(post(urlEqualTo("/v1/charges"))
                .willReturn(aResponse().withStatus(500)));

        assertThatThrownBy(() ->
                paymentClient.charge(new BigDecimal("29.99"), "tok_visa"))
                .isInstanceOf(ExternalServiceException.class);

        // Verify retry happened
        verify(3, postRequestedFor(urlEqualTo("/v1/charges")));
    }
}

Testcontainers Reuse

Speed up tests by reusing containers across test classes:

java
// Base class for all integration tests
@Testcontainers
public abstract class IntegrationTestBase {

    @Container
    @ServiceConnection  // Spring Boot 3.1+ auto-configuration
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
            .withReuse(true);  // Reuse across test runs

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer(
            DockerImageName.parse("redis:7"));

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

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

// Tests extend the base class
@SpringBootTest
class OrderIntegrationTest extends IntegrationTestBase {
    @Test
    void createOrder() { /* postgres and redis are already running */ }
}

Test Patterns Cheat Sheet

What to testAnnotationLoadsSpeed
Business logicNone (plain JUnit)NothingFastest
Controllers@WebMvcTestWeb layerFast
JPA Repositories@DataJpaTestJPA layerMedium
JSON serialization@JsonTestJackson onlyFast
Full API workflow@SpringBootTestEverythingSlow
Security rules@WebMvcTest + @Import(SecurityConfig)Web + securityFast
Kafka consumers@SpringBootTest + @EmbeddedKafkaEverythingSlow
External APIs@SpringBootTest + @WireMockTestEverythingSlow

Test naming convention

Use the pattern methodUnderTest_condition_expectedResult. Examples: placeOrder_insufficientStock_throwsException, findById_existingId_returnsProduct, createUser_duplicateEmail_returns409. This makes test failures self-documenting.

Further Reading

Common Pitfalls

Pitfall 1: Loading the full Spring context for every test

Using @SpringBootTest for all tests loads the entire application context, making test suites painfully slow (minutes instead of seconds). Fix: Use test slices: @WebMvcTest for controllers, @DataJpaTest for repositories, @JsonTest for serialization. Reserve @SpringBootTest for end-to-end integration tests.

Pitfall 2: Using H2 for repository tests instead of real databases

H2 behaves differently from PostgreSQL/MySQL for SQL syntax, constraints, JSON columns, and full-text search, giving false confidence. Fix: Use Testcontainers with a real database image matching production. Extend a base test class that starts the container once and reuses it across tests.

Pitfall 3: Over-mocking service internals

Mocking every dependency in a test verifies mock interactions, not actual behavior. Tests pass even when the real code is broken. Fix: Test behavior, not implementation. For unit tests, mock only external boundaries (databases, HTTP clients). For integration tests, use real services with Testcontainers.

Pitfall 4: Forgetting to test error paths and edge cases

Testing only the happy path means bugs in error handling, validation, and boundary conditions are not caught until production. Fix: For every test of the success path, write tests for: invalid input (400), missing resource (404), duplicate resource (409), unauthorized access (401/403), and downstream failures (502/503).

Pitfall 5: Not cleaning up test state between tests

Shared test state (database records, cache entries) from one test leaks into another, causing flaky tests that pass individually but fail together. Fix: Use @Transactional on test classes (auto-rollback), @DirtiesContext when needed, or @BeforeEach cleanup methods. Testcontainers with @Container provide isolation by default.

Pitfall 6: Creating new Testcontainers for every test class

Starting a new PostgreSQL container for each test class adds 5-10 seconds per class, making the suite very slow. Fix: Use shared containers with static container fields and @Container with withReuse(true). Or use a base class with @ServiceConnection that all integration tests extend.

Interview Questions

Q1: What is the difference between @WebMvcTest, @DataJpaTest, and @SpringBootTest?

Answer

@WebMvcTest loads only the web layer (controllers, filters, advice, converters) and provides MockMvc for testing HTTP requests without starting a server. Service dependencies are mocked with @MockBean. @DataJpaTest loads only the JPA layer (entities, repositories, EntityManager) with an embedded or test database. It auto-configures Flyway/Liquibase and rolls back transactions. @SpringBootTest loads the complete application context with all beans. Use it for end-to-end integration tests. Each slice test is faster because it loads fewer beans.

Q2: How does @MockBean differ from Mockito's mock() and when should you use each?

Answer

@MockBean is a Spring Test annotation that creates a Mockito mock AND registers it as a bean in the Spring context, replacing any existing bean of the same type. Use it in slice tests (@WebMvcTest, @DataJpaTest) to mock dependencies that the tested slice needs. Mockito's mock() creates a standalone mock without any Spring context involvement. Use it in pure unit tests (no Spring) where you instantiate the class under test with new MyService(mock1, mock2). @MockBean is slower because it requires a Spring context; mock() is faster for pure unit tests.

Q3: What is Testcontainers and how do you use it with Spring Boot?

Answer

Testcontainers is a Java library that runs Docker containers for integration tests. It provides pre-built modules for PostgreSQL, Redis, Kafka, Elasticsearch, and other infrastructure. In Spring Boot, you annotate a test with @Testcontainers, declare a @Container static field (e.g., PostgreSQLContainer), and use @DynamicPropertySource or @ServiceConnection (Spring Boot 3.1+) to inject container connection details into Spring properties. Containers start before tests and stop after. Use withReuse(true) to keep containers running across test runs for faster iteration.

Q4: How do you test controllers that require authentication?

Answer

Spring Security Test provides several approaches: (1) @WithMockUser(roles = "ADMIN") creates a mock authentication with specified roles. (2) @WithUserDetails("admin@test.com") loads a real user from UserDetailsService. (3) .with(jwt().jwt(j -> j.claim("roles", List.of("ADMIN")))) for OAuth2/JWT-protected endpoints. (4) .with(httpBasic("user", "password")) for Basic auth. Always test: unauthenticated access returns 401, unauthorized role returns 403, and authorized role returns the expected response.

Q5: What is the test naming convention methodUnderTest_condition_expectedResult and why use it?

Answer

This convention (e.g., placeOrder_insufficientStock_throwsException, findById_existingId_returnsProduct) makes test failures self-documenting. When a test fails in CI, the name immediately tells you what was tested, under what condition, and what was expected. You do not need to read the test code to understand the failure. It also enforces that each test method has a single, clear assertion. Alternative conventions like should_throwException_when_stockInsufficient or givenInsufficientStock_whenPlaceOrder_thenThrowsException (BDD style) serve the same purpose.

"What I cannot create, I do not understand." — Richard Feynman