Skip to main content

Spring Unit Testing

Introduction

Unit testing is a fundamental practice in software development that involves testing individual components or "units" of your application in isolation. In the context of Spring applications, unit testing allows you to verify that your controllers, services, repositories, and other components work correctly on their own, without the need to start the entire application context.

In this guide, we'll explore how to write effective unit tests for Spring applications using popular testing frameworks like JUnit and Mockito, combined with Spring's built-in testing support. We'll walk through practical examples to help you understand how to test various Spring components.

Prerequisites

Before we dive into Spring unit testing, make sure you have:

  • Basic knowledge of Java
  • Familiarity with Spring Framework concepts
  • Maven or Gradle for dependency management

Setting Up Your Testing Environment

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

For Maven (pom.xml):

xml
<dependencies>
<!-- Spring Boot Starter Test includes JUnit, Mockito, AssertJ, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

For Gradle (build.gradle):

groovy
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Unit Testing vs. Integration Testing

Before we start writing tests, let's clarify the difference:

  • Unit testing focuses on testing individual components in isolation, often mocking or stubbing dependencies.
  • Integration testing involves testing how multiple components work together, often with a partial or full application context.

This guide focuses on unit testing. For integration testing, check the "Spring Integration Testing" section.

Testing Spring Components

Let's explore how to test different Spring components:

Testing Service Layer

The service layer contains your business logic, and it's essential to test it thoroughly. Let's consider a simple UserService that manages users:

java
@Service
public class UserService {

private final UserRepository userRepository;

@Autowired
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();
}

public User createUser(User user) {
// Validate user data
if (user.getEmail() == null || user.getEmail().isEmpty()) {
throw new IllegalArgumentException("Email cannot be empty");
}

return userRepository.save(user);
}
}

Now, let's write unit tests for this service:

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

@Mock
private UserRepository userRepository;

@InjectMocks
private UserService userService;

@Test
public void testGetUserById_WhenUserExists_ShouldReturnUser() {
// Arrange
Long userId = 1L;
User expectedUser = new User();
expectedUser.setId(userId);
expectedUser.setName("John Doe");
expectedUser.setEmail("[email protected]");

when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

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

// Assert
assertEquals(expectedUser.getId(), actualUser.getId());
assertEquals(expectedUser.getName(), actualUser.getName());
assertEquals(expectedUser.getEmail(), actualUser.getEmail());

verify(userRepository, times(1)).findById(userId);
}

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

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

verify(userRepository, times(1)).findById(userId);
}

@Test
public void testCreateUser_WithValidUser_ShouldReturnSavedUser() {
// Arrange
User userToCreate = new User();
userToCreate.setName("Jane Smith");
userToCreate.setEmail("[email protected]");

User savedUser = new User();
savedUser.setId(1L);
savedUser.setName("Jane Smith");
savedUser.setEmail("[email protected]");

when(userRepository.save(any(User.class))).thenReturn(savedUser);

// Act
User result = userService.createUser(userToCreate);

// Assert
assertNotNull(result);
assertEquals(savedUser.getId(), result.getId());
assertEquals(savedUser.getName(), result.getName());
assertEquals(savedUser.getEmail(), result.getEmail());

verify(userRepository, times(1)).save(userToCreate);
}

@Test
public void testCreateUser_WithInvalidEmail_ShouldThrowException() {
// Arrange
User userWithInvalidEmail = new User();
userWithInvalidEmail.setName("Invalid User");
userWithInvalidEmail.setEmail(""); // Empty email

// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
userService.createUser(userWithInvalidEmail);
});

verify(userRepository, never()).save(any());
}
}

Key Points from the Service Test Example:

  1. We use @ExtendWith(MockitoExtension.class) for JUnit 5 tests with Mockito
  2. @Mock creates a mock implementation of the UserRepository
  3. @InjectMocks injects the mocks into the service we're testing
  4. We define test cases for both success and failure scenarios
  5. We use Mockito's when() to define behavior of our mocks
  6. We use JUnit's assertions to verify results
  7. We use Mockito's verify() to ensure methods were called as expected

Testing Controller Layer

Controllers handle HTTP requests and responses. Let's test a controller that uses our UserService:

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

private final UserService userService;

@Autowired
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 ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}

@PostMapping
public ResponseEntity<User> createUser(@RequestBody @Valid User user) {
User createdUser = userService.createUser(user);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(createdUser.getId())
.toUri();

return ResponseEntity.created(location).body(createdUser);
}
}

And here's how we can test it:

java
@ExtendWith(MockitoExtension.class)
public class UserControllerTest {

@Mock
private UserService userService;

@InjectMocks
private UserController userController;

private MockMvc mockMvc;

private ObjectMapper objectMapper = new ObjectMapper();

@BeforeEach
public void setup() {
mockMvc = MockMvcBuilders.standaloneSetup(userController)
.setControllerAdvice(new GlobalExceptionHandler()) // Include your exception handler if you have one
.build();
}

@Test
public void testGetUserById_WhenUserExists_ShouldReturnUser() throws Exception {
// Arrange
Long userId = 1L;
User user = new User();
user.setId(userId);
user.setName("John Doe");
user.setEmail("[email protected]");

when(userService.getUserById(userId)).thenReturn(user);

// Act & Assert
mockMvc.perform(get("/api/users/{id}", userId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(userId))
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.email").value("[email protected]"));

verify(userService, times(1)).getUserById(userId);
}

@Test
public void testGetUserById_WhenUserDoesNotExist_ShouldReturn404() throws Exception {
// Arrange
Long userId = 999L;
when(userService.getUserById(userId)).thenThrow(new UserNotFoundException("User not found with id: " + userId));

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

verify(userService, times(1)).getUserById(userId);
}

@Test
public void testCreateUser_WithValidData_ShouldReturnCreated() throws Exception {
// Arrange
User userToCreate = new User();
userToCreate.setName("Jane Smith");
userToCreate.setEmail("[email protected]");

User createdUser = new User();
createdUser.setId(1L);
createdUser.setName("Jane Smith");
createdUser.setEmail("[email protected]");

when(userService.createUser(any(User.class))).thenReturn(createdUser);

// Act & Assert
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userToCreate)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Jane Smith"))
.andExpect(jsonPath("$.email").value("[email protected]"))
.andExpect(header().exists("Location"));

verify(userService, times(1)).createUser(any(User.class));
}
}

Key Points from the Controller Test Example:

  1. We use MockMvc to simulate HTTP requests without starting a server
  2. We set up the MockMvc in the @BeforeEach method
  3. We mock the service layer to isolate the controller
  4. We test both successful and unsuccessful scenarios
  5. We use MockMvc's fluent API to make assertions about HTTP status, response body, and headers

Testing Repositories

For repository tests, you have two main approaches:

  1. Mock the repository interface (for pure unit testing)
  2. Use an in-memory database for lightweight integration tests

Let's focus on the first approach (mocking) to keep this as unit testing:

java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByEmail(String email);
boolean existsByEmail(String email);
}

Testing a service that uses this repository would be similar to our earlier examples:

java
@ExtendWith(MockitoExtension.class)
public class UserRegistrationServiceTest {

@Mock
private UserRepository userRepository;

@InjectMocks
private UserRegistrationService registrationService;

@Test
public void testRegisterUser_WithUniqueEmail_ShouldSucceed() {
// Arrange
User newUser = new User();
newUser.setName("Alice Wonder");
newUser.setEmail("[email protected]");
newUser.setPassword("password123");

when(userRepository.existsByEmail("[email protected]")).thenReturn(false);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User savedUser = invocation.getArgument(0);
savedUser.setId(1L); // Simulate database generating ID
return savedUser;
});

// Act
User registeredUser = registrationService.registerUser(newUser);

// Assert
assertNotNull(registeredUser);
assertEquals(1L, registeredUser.getId());
assertEquals("Alice Wonder", registeredUser.getName());
assertEquals("[email protected]", registeredUser.getEmail());

verify(userRepository).existsByEmail("[email protected]");
verify(userRepository).save(newUser);
}

@Test
public void testRegisterUser_WithDuplicateEmail_ShouldThrowException() {
// Arrange
User newUser = new User();
newUser.setName("Duplicate User");
newUser.setEmail("[email protected]");
newUser.setPassword("password123");

when(userRepository.existsByEmail("[email protected]")).thenReturn(true);

// Act & Assert
assertThrows(UserAlreadyExistsException.class, () -> {
registrationService.registerUser(newUser);
});

verify(userRepository).existsByEmail("[email protected]");
verify(userRepository, never()).save(any());
}
}

Testing Exception Handling

Testing exception scenarios is crucial. Here's how to test exceptions in your Spring components:

java
@ExtendWith(MockitoExtension.class)
public class ExceptionHandlingTest {

@Mock
private UserRepository userRepository;

@InjectMocks
private UserService userService;

@Test
public void testHandleDataAccessException() {
// Arrange
when(userRepository.findById(any())).thenThrow(new DataAccessResourceFailureException("Database connection failed"));

// Act & Assert
assertThrows(DatabaseConnectionException.class, () -> {
userService.getUserById(1L);
});
}

@Test
public void testHandleValidationException() {
// Arrange
User invalidUser = new User();
// Don't set required fields

// Act & Assert
assertThrows(ValidationException.class, () -> {
userService.createUser(invalidUser);
});
}
}

Testing Aspects and AOP

If your application uses Aspect-Oriented Programming (AOP), you might want to test those aspects:

java
@ExtendWith(MockitoExtension.class)
public class LoggingAspectTest {

private ApplicationContext applicationContext;
private Logger mockLogger;

@Before
public void setup() {
mockLogger = mock(Logger.class);

// Create test application context with the aspect
applicationContext = new AnnotationConfigApplicationContext(TestConfig.class);

// Inject mock logger into the aspect
LoggingAspect loggingAspect = applicationContext.getBean(LoggingAspect.class);
loggingAspect.setLogger(mockLogger);
}

@Test
public void testLoggingAspect() {
// Arrange
UserService userService = applicationContext.getBean(UserService.class);

// Act
userService.getUserById(1L);

// Assert
verify(mockLogger).info(contains("Method getUserById called with parameter: 1"));
}

@Configuration
@EnableAspectJAutoProxy
static class TestConfig {

@Bean
public UserService userService() {
return new UserService(userRepository());
}

@Bean
public UserRepository userRepository() {
return mock(UserRepository.class);
}

@Bean
public LoggingAspect loggingAspect() {
return new LoggingAspect();
}
}
}

Best Practices for Spring Unit Testing

  1. Test one thing per test method: Each test should focus on testing one specific behavior or scenario.

  2. Follow the AAA pattern:

    • Arrange: Set up the test data and conditions
    • Act: Perform the operation being tested
    • Assert: Verify the result is as expected
  3. Use meaningful test names: Name tests to reflect what they're testing, such as testCreateUser_WithInvalidEmail_ShouldThrowException().

  4. Mock external dependencies: Use Mockito to replace external dependencies with controlled mock implementations.

  5. Test both success and failure cases: Don't just test the happy path; also test how your code handles errors.

  6. Keep tests independent: One test should not depend on the outcome of another test.

  7. Clean up after tests: Use @AfterEach or @AfterAll to clean up resources if needed.

  8. Use assertions effectively: Be specific about what you're asserting and include meaningful error messages.

Common Testing Annotations

Here's a quick reference for common annotations used in Spring unit testing:

  • JUnit Annotations:

    • @Test: Marks a method as a test method
    • @BeforeEach: Method runs before each test
    • @AfterEach: Method runs after each test
    • @BeforeAll: Method runs once before all tests in the class
    • @AfterAll: Method runs once after all tests in the class
  • Mockito Annotations:

    • @Mock: Creates a mock object
    • @Spy: Creates a spy of a real object
    • @InjectMocks: Injects mock objects into the tested object
    • @Captor: Creates an argument captor
  • Spring Test Annotations:

    • @MockBean: Creates and injects a Mockito mock for a Spring bean
    • @SpyBean: Creates and injects a Mockito spy for a Spring bean
    • @WebMvcTest: Loads only web-related beans for controller testing
    • @DataJpaTest: Configures in-memory database for repository testing

Summary

In this guide, we've covered the fundamentals of unit testing Spring applications:

  • Setting up a testing environment with JUnit and Mockito
  • Testing services, controllers, and repositories
  • Handling exceptions in tests
  • Testing aspects and AOP
  • Following best practices for effective unit testing

Unit testing is a critical skill for Spring developers. By writing thorough unit tests, you can catch bugs early, ensure your code works as expected, and make changes with confidence.

Additional Resources

Exercises

  1. Write unit tests for a ProductService that has methods to find products by category, add a new product, and update product inventory.

  2. Create a controller test for a ProductController that uses the ProductService from exercise 1.

  3. Write tests for a custom validation aspect that verifies product prices are positive before saving.

  4. Create a comprehensive test suite for a user registration flow, including validation, duplicate checking, and confirmation emails.

  5. Refactor an existing test class to follow the best practices outlined in this guide.

Happy testing!



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