Introduction

Testing is crucial for building reliable Spring Boot applications. This guide covers everything from unit tests to integration tests, helping you write effective tests that catch bugs early and document your code’s behavior.

Whether you’re new to testing or looking to improve your testing strategy, you’ll find practical examples and best practices here.

Testing Pyramid

Spring Boot applications should have:

  • Unit Tests (70%): Test individual components in isolation

  • Integration Tests (20%): Test component interactions

  • End-to-End Tests (10%): Test complete user flows

Setup

Dependencies

Add these to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- For WebTestClient -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <scope>test</scope>
</dependency>

<!-- For Testcontainers (optional) -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>

spring-boot-starter-test includes:

  • JUnit 5

  • Mockito

  • AssertJ

  • Hamcrest

  • JSONassert

  • JsonPath

Unit Testing

Testing Services

Service layer tests should be fast and isolated:

@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {

    @Mock
    private CustomerRepository repository;

    @InjectMocks
    private CustomerService service;

    @Test
    void shouldFindCustomerById() {
        // Given
        Long customerId = 1L;
        Customer expected = new Customer(customerId, "Alice", "alice@example.com");
        when(repository.findById(customerId))
            .thenReturn(Optional.of(expected));

        // When
        Customer actual = service.findById(customerId);

        // Then
        assertThat(actual).isEqualTo(expected);
        verify(repository).findById(customerId);
    }

    @Test
    void shouldThrowExceptionWhenCustomerNotFound() {
        // Given
        Long customerId = 999L;
        when(repository.findById(customerId))
            .thenReturn(Optional.empty());

        // When & Then
        assertThatThrownBy(() -> service.findById(customerId))
            .isInstanceOf(CustomerNotFoundException.class)
            .hasMessageContaining("Customer not found: 999");
    }

    @Test
    void shouldCreateCustomer() {
        // Given
        Customer newCustomer = new Customer(null, "Bob", "bob@example.com");
        Customer saved = new Customer(2L, "Bob", "bob@example.com");
        when(repository.save(any(Customer.class))).thenReturn(saved);

        // When
        Customer result = service.createCustomer(newCustomer);

        // Then
        assertThat(result.getId()).isEqualTo(2L);
        assertThat(result.getName()).isEqualTo("Bob");

        ArgumentCaptor<Customer> captor = ArgumentCaptor.forClass(Customer.class);
        verify(repository).save(captor.capture());
        assertThat(captor.getValue().getName()).isEqualTo("Bob");
    }
}

Testing with @SpringBootTest

For tests that need the Spring context:

@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @MockBean
    private PaymentService paymentService;

    @MockBean
    private InventoryService inventoryService;

    @Test
    void shouldProcessOrder() {
        // Given
        Order order = new Order(/* ... */);
        when(inventoryService.checkAvailability(any()))
            .thenReturn(true);
        when(paymentService.charge(any()))
            .thenReturn(new PaymentResult(true, "TXN123"));

        // When
        OrderResult result = orderService.processOrder(order);

        // Then
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getTransactionId()).isEqualTo("TXN123");

        verify(inventoryService).reserveItems(order.getItems());
        verify(paymentService).charge(order);
    }
}
Use @MockBean to replace specific beans in the application context during tests.

Controller Testing

Using @WebMvcTest

Test controllers without starting the full application:

@WebMvcTest(CustomerController.class)
class CustomerControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private CustomerService service;

    @Test
    void shouldReturnCustomerById() throws Exception {
        // Given
        Long customerId = 1L;
        Customer customer = new Customer(customerId, "Alice", "alice@example.com");
        when(service.findById(customerId)).thenReturn(customer);

        // When & Then
        mockMvc.perform(get("/api/customers/{id}", customerId))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));

        verify(service).findById(customerId);
    }

    @Test
    void shouldReturn404WhenCustomerNotFound() throws Exception {
        // Given
        Long customerId = 999L;
        when(service.findById(customerId))
            .thenThrow(new CustomerNotFoundException(customerId));

        // When & Then
        mockMvc.perform(get("/api/customers/{id}", customerId))
            .andExpect(status().isNotFound());
    }

    @Test
    void shouldCreateCustomer() throws Exception {
        // Given
        CustomerDTO newCustomer = new CustomerDTO(null, "Bob", "bob@example.com");
        Customer saved = new Customer(2L, "Bob", "bob@example.com");
        when(service.createCustomer(any())).thenReturn(saved);

        // When & Then
        mockMvc.perform(post("/api/customers")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "Bob",
                        "email": "bob@example.com"
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"))
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(jsonPath("$.name").value("Bob"));
    }

    @Test
    void shouldValidateInput() throws Exception {
        // When & Then
        mockMvc.perform(post("/api/customers")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "",
                        "email": "invalid-email"
                    }
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }
}

Using WebTestClient

Modern alternative to MockMvc with fluent API:

@WebMvcTest(CustomerController.class)
@AutoConfigureWebTestClient
class CustomerControllerWebTestClientTest {

    @Autowired
    private WebTestClient webTestClient;

    @MockBean
    private CustomerService service;

    @Test
    void shouldReturnCustomer() {
        // Given
        Customer customer = new Customer(1L, "Alice", "alice@example.com");
        when(service.findById(1L)).thenReturn(customer);

        // When & Then
        webTestClient.get()
            .uri("/api/customers/1")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.name").isEqualTo("Alice")
            .jsonPath("$.email").isEqualTo("alice@example.com");
    }

    @Test
    void shouldCreateCustomer() {
        // Given
        Customer saved = new Customer(2L, "Bob", "bob@example.com");
        when(service.createCustomer(any())).thenReturn(saved);

        // When & Then
        webTestClient.post()
            .uri("/api/customers")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(new CustomerDTO(null, "Bob", "bob@example.com"))
            .exchange()
            .expectStatus().isCreated()
            .expectHeader().exists("Location")
            .expectBody(Customer.class)
            .value(customer -> {
                assertThat(customer.getId()).isEqualTo(2L);
                assertThat(customer.getName()).isEqualTo("Bob");
            });
    }
}

Repository Testing

Using @DataJpaTest

Test JPA repositories with an in-memory database:

@DataJpaTest
class CustomerRepositoryTest {

    @Autowired
    private CustomerRepository repository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindCustomerByEmail() {
        // Given
        Customer customer = new Customer(null, "Alice", "alice@example.com");
        entityManager.persist(customer);
        entityManager.flush();

        // When
        Optional<Customer> found = repository.findByEmail("alice@example.com");

        // Then
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Alice");
    }

    @Test
    void shouldFindCustomersByNameContaining() {
        // Given
        entityManager.persist(new Customer(null, "Alice Smith", "alice@example.com"));
        entityManager.persist(new Customer(null, "Bob Smith", "bob@example.com"));
        entityManager.persist(new Customer(null, "Charlie Jones", "charlie@example.com"));
        entityManager.flush();

        // When
        List<Customer> smiths = repository.findByNameContaining("Smith");

        // Then
        assertThat(smiths).hasSize(2);
        assertThat(smiths)
            .extracting(Customer::getName)
            .containsExactlyInAnyOrder("Alice Smith", "Bob Smith");
    }

    @Test
    void shouldCountCustomersByDomain() {
        // Given
        entityManager.persist(new Customer(null, "User1", "user1@example.com"));
        entityManager.persist(new Customer(null, "User2", "user2@example.com"));
        entityManager.persist(new Customer(null, "User3", "user3@gmail.com"));
        entityManager.flush();

        // When
        long count = repository.countByEmailEndingWith("@example.com");

        // Then
        assertThat(count).isEqualTo(2);
    }
}
@DataJpaTest automatically configures an in-memory database and rolls back transactions after each test.

Integration Testing

Full Integration Tests

Test the complete application:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "spring.jpa.hibernate.ddl-auto=create-drop"
})
class CustomerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private CustomerRepository repository;

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

    @Test
    void shouldCreateAndRetrieveCustomer() {
        // Create
        CustomerDTO newCustomer = new CustomerDTO(null, "Alice", "alice@example.com");

        ResponseEntity<Customer> createResponse = restTemplate.postForEntity(
            "/api/customers",
            newCustomer,
            Customer.class
        );

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).isNotNull();
        Long customerId = createResponse.getBody().getId();

        // Retrieve
        ResponseEntity<Customer> getResponse = restTemplate.getForEntity(
            "/api/customers/" + customerId,
            Customer.class
        );

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getName()).isEqualTo("Alice");
    }

    @Test
    void shouldUpdateCustomer() {
        // Given - create a customer
        Customer existing = repository.save(
            new Customer(null, "Bob", "bob@example.com")
        );

        // When - update
        CustomerDTO update = new CustomerDTO(
            existing.getId(),
            "Bob Smith",
            "bob.smith@example.com"
        );

        restTemplate.put("/api/customers/" + existing.getId(), update);

        // Then - verify
        Customer updated = repository.findById(existing.getId()).orElseThrow();
        assertThat(updated.getName()).isEqualTo("Bob Smith");
        assertThat(updated.getEmail()).isEqualTo("bob.smith@example.com");
    }

    @Test
    void shouldDeleteCustomer() {
        // Given
        Customer customer = repository.save(
            new Customer(null, "Charlie", "charlie@example.com")
        );

        // When
        restTemplate.delete("/api/customers/" + customer.getId());

        // Then
        assertThat(repository.findById(customer.getId())).isEmpty();
    }
}

Testing with Testcontainers

Use real databases in tests:

@SpringBootTest
@Testcontainers
class CustomerRepositoryTestcontainersTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @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 CustomerRepository repository;

    @Test
    void shouldPersistCustomer() {
        // Given
        Customer customer = new Customer(null, "Alice", "alice@example.com");

        // When
        Customer saved = repository.save(customer);

        // Then
        assertThat(saved.getId()).isNotNull();
        assertThat(repository.findById(saved.getId())).isPresent();
    }
}

Testing Asynchronous Code

Testing @Async Methods

@SpringBootTest
class EmailServiceTest {

    @Autowired
    private EmailService emailService;

    @Test
    void shouldSendEmailAsynchronously() throws Exception {
        // When
        CompletableFuture<Void> future = emailService.sendEmail(
            "test@example.com",
            "Test Subject",
            "Test Body"
        );

        // Then
        future.get(5, TimeUnit.SECONDS); // Wait for completion

        // Verify email was sent (using mock SMTP or email service)
        // ...
    }
}

Testing Event Listeners

@SpringBootTest
class OrderEventListenerTest {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @MockBean
    private NotificationService notificationService;

    @Test
    void shouldHandleOrderCreatedEvent() {
        // Given
        Order order = new Order(1L, "customer@example.com", /* ... */);
        OrderCreatedEvent event = new OrderCreatedEvent(this, order);

        // When
        eventPublisher.publishEvent(event);

        // Then - wait a bit for async processing
        await().atMost(2, TimeUnit.SECONDS)
            .untilAsserted(() ->
                verify(notificationService).sendOrderConfirmation(order)
            );
    }
}

Best Practices

Use Test Fixtures

Create reusable test data:

public class TestDataFactory {

    public static Customer createCustomer(String name, String email) {
        return new Customer(null, name, email);
    }

    public static Customer createCustomerWithId(Long id, String name, String email) {
        return new Customer(id, name, email);
    }

    public static List<Customer> createCustomers(int count) {
        return IntStream.range(0, count)
            .mapToObj(i -> createCustomer("Customer" + i, "customer" + i + "@example.com"))
            .collect(Collectors.toList());
    }
}

// Usage in tests
@Test
void testWithFixture() {
    Customer customer = TestDataFactory.createCustomer("Alice", "alice@example.com");
    // ...
}

Use AssertJ for Fluent Assertions

@Test
void demonstrateAssertJ() {
    List<Customer> customers = service.findAll();

    assertThat(customers)
        .isNotNull()
        .hasSize(3)
        .extracting(Customer::getName)
        .containsExactlyInAnyOrder("Alice", "Bob", "Charlie");

    assertThat(customers)
        .filteredOn(c -> c.getEmail().endsWith("@example.com"))
        .hasSize(2);

    Customer customer = customers.get(0);
    assertThat(customer)
        .hasFieldOrPropertyWithValue("name", "Alice")
        .extracting(Customer::getEmail)
        .asString()
        .startsWith("alice");
}

Test Naming Conventions

Use descriptive test names:

// Good - describes what is being tested
@Test
void shouldReturnCustomerWhenValidIdProvided() { }

@Test
void shouldThrowExceptionWhenCustomerNotFound() { }

@Test
void shouldValidateEmailFormat() { }

// Avoid - vague names
@Test
void testCustomer() { }

@Test
void test1() { }

Arrange-Act-Assert Pattern

Structure tests clearly:

@Test
void shouldCalculateOrderTotal() {
    // Arrange (Given)
    Order order = new Order();
    order.addItem(new Item("Product1", 10.00, 2));
    order.addItem(new Item("Product2", 5.00, 3));

    // Act (When)
    BigDecimal total = order.calculateTotal();

    // Assert (Then)
    assertThat(total).isEqualByComparingTo(new BigDecimal("35.00"));
}

Common Pitfalls

Don’t Test Framework Code

// Bad - testing Spring's autowiring
@Test
void shouldInjectDependencies() {
    assertThat(customerService).isNotNull();
    assertThat(customerRepository).isNotNull();
}

// Good - test your business logic
@Test
void shouldApplyDiscountForPremiumCustomers() {
    // Test actual behavior
}

Avoid Test Interdependence

// Bad - tests depend on execution order
private static Customer sharedCustomer;

@Test
void test1_createCustomer() {
    sharedCustomer = service.create(new Customer(/* ... */));
}

@Test
void test2_updateCustomer() {
    service.update(sharedCustomer); // Depends on test1!
}

// Good - each test is independent
@Test
void shouldCreateCustomer() {
    Customer customer = service.create(new Customer(/* ... */));
    assertThat(customer.getId()).isNotNull();
}

@Test
void shouldUpdateCustomer() {
    Customer customer = service.create(new Customer(/* ... */));
    service.update(customer);
    // ...
}

Clean Up Test Data

@SpringBootTest
class CustomerServiceTest {

    @Autowired
    private CustomerRepository repository;

    @AfterEach
    void cleanUp() {
        repository.deleteAll();
    }

    // Or use @Transactional for automatic rollback
}

Conclusion

Effective testing is essential for maintaining Spring Boot applications. Key takeaways:

  • Write unit tests for business logic with mocks

  • Use @WebMvcTest for controller tests

  • Use @DataJpaTest for repository tests

  • Use Testcontainers for integration tests with real databases

  • Follow the testing pyramid: more unit tests, fewer integration tests

  • Keep tests independent, readable, and maintainable

Good tests serve as documentation and enable confident refactoring.

Further Reading

About the Author

Wei Li

Wei Li is a passionate software developer and technical writer specializing in Java and Spring Framework. With years of experience in building enterprise applications, Wei Li enjoys sharing knowledge through detailed tutorials and practical guides.

For questions or feedback, feel free to reach out at weili.mail@gmail.com.