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:
- Confidence in Refactoring: Tests ensure your code still works after making changes
- Documentation: Tests serve as living documentation of how your code should behave
- Design Feedback: Writing tests often reveals design flaws early
- 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:
<!-- For Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
For Gradle:
// 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:
// 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:
// 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
// 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:
// 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 contextWebEnvironment.RANDOM_PORT
starts the embedded server on a random port@MockBean
replaces the real bean with a mock in the Spring contextTestRestTemplate
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
// 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 contextMockMvc
allows testing controllers without starting a serverandExpect
assertions verify the HTTP response
Example: Testing a Repository with @DataJpaTest
// 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:
// 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:
# 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:
@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:
@ActiveProfiles("test")
@SpringBootTest
class ProfileSpecificTest {
// Tests that run with the "test" profile active
}
And in your application-test.properties
:
# Test profile configuration
feature.flag.new-feature=true
2. Testing Asynchronous Code
Test methods annotated with @Async
:
@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:
@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:
// 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:
@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:
- Unit Testing with JUnit and Mockito to test individual components
- Integration Testing with
@SpringBootTest
to verify component interactions - Test Slices like
@WebMvcTest
and@DataJpaTest
for focused layer testing - Best Practices for organizing and structuring your tests
- Advanced Techniques for testing profiles, async code, and scheduled tasks
- 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
Exercises
- Create a simple Spring Boot REST API with a controller, service, and repository, and write comprehensive tests for each layer.
- Add validation to your API and write tests to verify that invalid requests are rejected.
- Implement security using Spring Security, and write tests that verify authorized and unauthorized access.
- Create a scheduled task that runs daily and write tests to verify its functionality.
- 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! :)