Skip to main content

Spring Boot Testing

Introduction

Testing is a critical part of any software development process, and Spring Boot provides excellent support for testing your applications. As a beginner, understanding how to properly test your Spring Boot applications can help you build more reliable, maintainable, and robust software.

In this guide, we'll explore the various testing approaches available in Spring Boot. We'll cover unit testing, integration testing, test slices, and more, with practical examples to help you implement effective testing strategies in your own projects.

Why Testing Matters in Spring Boot

Before diving into the technical details, let's understand why testing is particularly important in Spring Boot applications:

  1. Confidence in Refactoring: Tests ensure your code still works after making changes
  2. Documentation: Tests serve as living documentation of how your code should behave
  3. Design Feedback: Writing tests often reveals design flaws early
  4. Spring's Complexity: Spring's dependency injection and lifecycle management benefit greatly from automated testing

Spring Boot Testing Essentials

Spring Boot provides a comprehensive testing framework built on top of JUnit, making it easier to test various aspects of your application.

Getting Started with Dependencies

First, let's add the necessary dependencies to your project:

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

For Gradle:

groovy
// For Gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'

The spring-boot-starter-test includes:

  • JUnit 5
  • Spring Test & Spring Boot Test
  • AssertJ
  • Hamcrest
  • Mockito
  • JSONassert
  • JsonPath

Types of Tests in Spring Boot

1. Unit Testing

Unit tests focus on testing a single component in isolation, typically a method within a class. External dependencies are usually mocked or stubbed.

Example: Testing a Service Class

Let's create a simple service and test it:

java
// UserService.java
@Service
public class UserService {

private final UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
}

public List<User> getAllUsers() {
return userRepository.findAll();
}
}

Now, let's write a unit test for this service:

java
// UserServiceTest.java
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

@Mock
private UserRepository userRepository;

@InjectMocks
private UserService userService;

@Test
void getUserById_shouldReturnUser_whenUserExists() {
// Arrange
Long userId = 1L;
User expectedUser = new User(userId, "John", "Doe");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

// Act
User actualUser = userService.getUserById(userId);

// Assert
assertEquals(expectedUser, actualUser);
verify(userRepository).findById(userId);
}

@Test
void getUserById_shouldThrowException_whenUserDoesNotExist() {
// Arrange
Long userId = 1L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());

// Act & Assert
assertThrows(UserNotFoundException.class, () -> userService.getUserById(userId));
verify(userRepository).findById(userId);
}
}

In this example:

  • @ExtendWith(MockitoExtension.class) enables Mockito support for JUnit 5
  • @Mock creates a mock implementation of UserRepository
  • @InjectMocks injects the mocks into the service being tested
  • We follow the Arrange-Act-Assert pattern for clear test structure

2. Integration Testing

Integration tests verify that different components work together correctly. Spring Boot provides @SpringBootTest for this purpose.

Example: Testing a REST Controller with Service Integration

java
// UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {

private final UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}

@GetMapping
public List<User> getAllUsers() {
return userService.getAllUsers();
}
}

Now, let's write an integration test:

java
// UserControllerIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {

@Autowired
private TestRestTemplate restTemplate;

@MockBean
private UserService userService;

@Test
void getUserById_shouldReturnUser() {
// Arrange
Long userId = 1L;
User mockUser = new User(userId, "John", "Doe");
when(userService.getUserById(userId)).thenReturn(mockUser);

// Act
ResponseEntity<User> response = restTemplate.getForEntity("/api/users/{id}", User.class, userId);

// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(mockUser, response.getBody());
}

@Test
void getUserById_shouldReturn404_whenUserNotFound() {
// Arrange
Long userId = 999L;
when(userService.getUserById(userId)).thenThrow(new UserNotFoundException("Not found"));

// Act
ResponseEntity<String> response = restTemplate.getForEntity("/api/users/{id}", String.class, userId);

// Assert
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
}

Key points:

  • @SpringBootTest loads the entire application context
  • WebEnvironment.RANDOM_PORT starts the embedded server on a random port
  • @MockBean replaces the real bean with a mock in the Spring context
  • TestRestTemplate is used for making HTTP requests to your app

3. Test Slices

Spring Boot provides "test slices" that load only specific parts of your application, making tests faster and more focused.

Common Test Slice Annotations

  • @WebMvcTest: For testing MVC controllers without starting a full HTTP server
  • @DataJpaTest: For testing JPA repositories
  • @JsonTest: For testing JSON serialization/deserialization
  • @RestClientTest: For testing REST clients

Example: Testing a Controller with @WebMvcTest

java
// UserControllerTest.java
@WebMvcTest(UserController.class)
class UserControllerTest {

@MockBean
private UserService userService;

@Autowired
private MockMvc mockMvc;

@Test
void getUserById_shouldReturnUser() throws Exception {
// Arrange
Long userId = 1L;
User user = new User(userId, "John", "Doe");
when(userService.getUserById(userId)).thenReturn(user);

// Act & Assert
mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(userId))
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.lastName").value("Doe"));
}

@Test
void getUserById_shouldReturn404_whenUserNotFound() throws Exception {
// Arrange
Long userId = 999L;
when(userService.getUserById(userId)).thenThrow(new UserNotFoundException("Not found"));

// Act & Assert
mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isNotFound());
}
}

Key points:

  • @WebMvcTest only loads the web layer, not the entire context
  • MockMvc allows testing controllers without starting a server
  • andExpect assertions verify the HTTP response

Example: Testing a Repository with @DataJpaTest

java
// UserRepositoryTest.java
@DataJpaTest
class UserRepositoryTest {

@Autowired
private UserRepository userRepository;

@Test
void findByEmail_shouldReturnUser_whenUserExists() {
// Arrange
User user = new User(null, "John", "Doe");
user.setEmail("[email protected]");
userRepository.save(user);

// Act
Optional<User> result = userRepository.findByEmail("[email protected]");

// Assert
assertTrue(result.isPresent());
assertEquals("John", result.get().getFirstName());
}

@Test
void findByEmail_shouldReturnEmpty_whenUserDoesNotExist() {
// Act
Optional<User> result = userRepository.findByEmail("[email protected]");

// Assert
assertTrue(result.isEmpty());
}
}

Key points:

  • @DataJpaTest configures an in-memory database and JPA repositories
  • Tests are automatically transactional and rolled back after each test
  • Only JPA components are loaded, not the entire context

Testing Best Practices for Spring Boot

1. Use Appropriate Test Types

  • Unit tests: For isolated logic, business rules, and algorithms
  • Integration tests: For verifying component interactions
  • Slice tests: For focused testing of specific layers
  • End-to-end tests: For verifying complete workflows

2. Organize Your Tests

Follow this package structure:

src/
├── main/java/com/example/
│ └── myapp/
│ ├── controller/
│ ├── service/
│ └── repository/
└── test/java/com/example/
└── myapp/
├── controller/
├── service/
└── repository/

3. Use Test Fixtures

Create reusable test data:

java
// UserTestFixture.java
public class UserTestFixture {

public static User createDefaultUser() {
return new User(1L, "John", "Doe", "[email protected]");
}

public static List<User> createUserList(int count) {
return IntStream.rangeClosed(1, count)
.mapToObj(i -> new User(
(long) i,
"User" + i,
"LastName" + i,
"user" + i + "@example.com"
))
.collect(Collectors.toList());
}
}

4. Use Test Properties Files

Create a src/test/resources/application.properties file for test-specific configuration:

properties
# Test-specific configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

5. Test Configuration with @TestConfiguration

Create specific beans for testing:

java
@TestConfiguration
public class TestConfig {

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

@Bean
public Clock clock() {
return Clock.fixed(
Instant.parse("2023-01-01T10:00:00Z"),
ZoneId.systemDefault()
);
}
}

Advanced Testing Techniques

1. Testing with Profiles

Use Spring's profiles to modify behavior during tests:

java
@ActiveProfiles("test")
@SpringBootTest
class ProfileSpecificTest {
// Tests that run with the "test" profile active
}

And in your application-test.properties:

properties
# Test profile configuration
feature.flag.new-feature=true

2. Testing Asynchronous Code

Test methods annotated with @Async:

java
@SpringBootTest
class AsyncServiceTest {

@Autowired
private AsyncService asyncService;

@Test
void testAsyncMethod() throws Exception {
// Arrange
CompletableFuture<String> future = asyncService.asyncOperation();

// Act & Assert
String result = future.get(5, TimeUnit.SECONDS); // Wait with timeout
assertEquals("expected result", result);
}
}

3. Testing Scheduled Tasks

Test scheduled methods by directly invoking them:

java
@SpringBootTest
class ScheduledTasksTest {

@Autowired
private ScheduledTasks tasks;

@MockBean
private EmailService emailService;

@Test
void sendDailyReportTask_shouldSendEmail() {
// Act
tasks.sendDailyReport();

// Assert
verify(emailService).sendDailyReport();
}
}

Real-World Application: E-commerce API Testing

Let's create a more complex example testing an e-commerce API:

java
// Product.java
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
private Integer stock;

// Constructors, getters, setters
}

// Order.java
@Entity
@Table(name = "orders") // "order" is a reserved SQL keyword
public class Order {
@Id @GeneratedValue
private Long id;

@ManyToOne
private User customer;

@OneToMany(cascade = CascadeType.ALL)
private List<OrderItem> items = new ArrayList<>();

private LocalDateTime orderDate;
private OrderStatus status;

// Methods, getters, setters
public BigDecimal getTotalAmount() {
return items.stream()
.map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

// OrderService.java
@Service
public class OrderService {

private final OrderRepository orderRepository;
private final ProductRepository productRepository;

public OrderService(OrderRepository orderRepository, ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}

@Transactional
public Order createOrder(User customer, Map<Long, Integer> productQuantities) {
Order order = new Order();
order.setCustomer(customer);
order.setOrderDate(LocalDateTime.now());
order.setStatus(OrderStatus.PENDING);

for (Map.Entry<Long, Integer> entry : productQuantities.entrySet()) {
Long productId = entry.getKey();
Integer quantity = entry.getValue();

Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("Product not found: " + productId));

if (product.getStock() < quantity) {
throw new InsufficientStockException("Not enough stock for product: " + product.getName());
}

// Update product stock
product.setStock(product.getStock() - quantity);
productRepository.save(product);

// Add to order
OrderItem item = new OrderItem();
item.setProduct(product);
item.setQuantity(quantity);
item.setPrice(product.getPrice());
order.getItems().add(item);
}

return orderRepository.save(order);
}
}

Now, let's test this service:

java
@SpringBootTest
class OrderServiceIntegrationTest {

@Autowired
private OrderService orderService;

@Autowired
private ProductRepository productRepository;

@Autowired
private UserRepository userRepository;

private User testUser;
private Product product1;
private Product product2;

@BeforeEach
void setUp() {
// Create test user
testUser = new User(null, "Test", "User", "[email protected]");
userRepository.save(testUser);

// Create test products
product1 = new Product(null, "Laptop", new BigDecimal("999.99"), 10);
product2 = new Product(null, "Phone", new BigDecimal("499.99"), 20);
productRepository.saveAll(Arrays.asList(product1, product2));
}

@Test
void createOrder_shouldCreateOrderAndUpdateStock() {
// Arrange
Map<Long, Integer> productQuantities = new HashMap<>();
productQuantities.put(product1.getId(), 2); // 2 laptops
productQuantities.put(product2.getId(), 1); // 1 phone

// Act
Order order = orderService.createOrder(testUser, productQuantities);

// Assert
assertNotNull(order.getId());
assertEquals(testUser, order.getCustomer());
assertEquals(2, order.getItems().size());
assertEquals(OrderStatus.PENDING, order.getStatus());

// Check order total
BigDecimal expectedTotal = new BigDecimal("2499.97"); // 2 laptops + 1 phone
assertEquals(0, expectedTotal.compareTo(order.getTotalAmount()));

// Verify stock was updated
Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
Product updatedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
assertEquals(8, updatedProduct1.getStock()); // 10 - 2 = 8
assertEquals(19, updatedProduct2.getStock()); // 20 - 1 = 19
}

@Test
void createOrder_shouldThrowException_whenInsufficientStock() {
// Arrange
Map<Long, Integer> productQuantities = new HashMap<>();
productQuantities.put(product1.getId(), 11); // More than available stock

// Act & Assert
assertThrows(InsufficientStockException.class, () -> {
orderService.createOrder(testUser, productQuantities);
});

// Verify stock wasn't changed
Product product = productRepository.findById(product1.getId()).orElseThrow();
assertEquals(10, product.getStock());
}
}

Summary

In this guide, we've explored the fundamentals of testing Spring Boot applications:

  1. Unit Testing with JUnit and Mockito to test individual components
  2. Integration Testing with @SpringBootTest to verify component interactions
  3. Test Slices like @WebMvcTest and @DataJpaTest for focused layer testing
  4. Best Practices for organizing and structuring your tests
  5. Advanced Techniques for testing profiles, async code, and scheduled tasks
  6. Real-World Example testing a complex e-commerce service

Spring Boot's testing support makes it easier to create reliable, maintainable applications. By adopting a comprehensive testing strategy, you can confidently refactor and extend your application while ensuring it continues to work as expected.

Additional Resources

  1. Spring Boot Testing Documentation
  2. JUnit 5 User Guide
  3. Mockito Documentation

Exercises

  1. Create a simple Spring Boot REST API with a controller, service, and repository, and write comprehensive tests for each layer.
  2. Add validation to your API and write tests to verify that invalid requests are rejected.
  3. Implement security using Spring Security, and write tests that verify authorized and unauthorized access.
  4. Create a scheduled task that runs daily and write tests to verify its functionality.
  5. Implement error handling in your API and write tests to verify custom error responses.

By completing these exercises, you'll gain practical experience with testing Spring Boot applications and develop a robust approach to quality assurance in your projects.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)