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.
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);
// ...
}
Conclusion
Effective testing is essential for maintaining Spring Boot applications. Key takeaways:
-
Write unit tests for business logic with mocks
-
Use
@WebMvcTestfor controller tests -
Use
@DataJpaTestfor 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.
About the Author
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. |