17 - Spring Boot Unit Test
1. Introduction to Unit Testing
Unit Testing is the practice of testing a small, isolated piece of code, usually a single method or class, to verify that it behaves as expected.
In a Java application, the most common unit-testing tools are:
- JUnit 5 β test framework used to define and execute tests.
- Mockito β mocking framework used to create test doubles and isolate the class under test.
- AssertJ β optional assertion library that provides a fluent API for assertions.
- Spring Boot Test β provides integration-testing capabilities when we need to test Spring components together.
The main idea behind a unit test is:
Test one unit of behavior in isolation.
For example, suppose we have:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(User user) {
return userRepository.save(user);
}
}
A unit test should test the behavior of UserService without requiring:
- A real database
- A running Spring application
- HTTP requests
- External APIs
- Other infrastructure
Instead, we can replace UserRepository with a Mockito mock.
2. Why Unit Tests Are Important
Unit tests provide several important benefits.
2.1 Catch Bugs Early
A unit test can detect a regression immediately after a change is introduced.
For example:
@Test
void shouldCalculateTotalPrice() {
var result = calculator.calculate(100, 10);
assertEquals(110, result);
}
If someone changes the implementation and the result becomes 100, the test immediately fails.
2.2 Enable Refactoring
Good unit tests give developers confidence when changing existing code.
Imagine we have a complex service with hundreds of lines of code.
Without tests:
Change code
β
Hope nothing broke
β
Deploy
β
Find out later
With tests:
Change code
β
Run tests
β
Tests pass
β
Much higher confidence
Unit tests therefore act as a safety net during refactoring.
2.3 Document Expected Behavior
Tests can also serve as executable documentation.
For example:
@Test
void shouldRejectUserWhenEmailAlreadyExists() {
// ...
}
Even without reading the implementation, we can understand an important business rule:
A user cannot be created when the email already exists.
This is one of the strongest benefits of well-written tests.
2.4 Improve Code Design
Writing unit tests often exposes poorly designed code.
For example:
public class UserService {
public void createUser() {
// Database
// HTTP request
// File system
// Business logic
// Email
// Logging
// ...
}
}
This class is difficult to test because it has too many responsibilities.
After applying separation of concerns:
UserService
β
UserRepository
UserService
β
EmailService
UserService
β
UserValidator
Each dependency can be mocked independently.
Therefore:
Testability is often a signal of good software design.
3. Types of Tests
Before focusing on unit tests, it is important to understand where they fit in the testing pyramid.
/\
/ \
/ E2E\
/------\
/ Integ \
/----------\
/ Unit \
/--------------\
Unit Tests
Test a small piece of code in isolation.
Example:
UserService
β
Mockito Mock
β
UserRepository
Characteristics:
- Very fast
- Isolated
- Usually no Spring context
- No real database
- No network calls
Integration Tests
Test multiple components working together.
For example:
Controller
β
Service
β
Repository
β
Database
Integration tests are slower but verify that components actually integrate correctly.
End-to-End Tests
Test the complete application from the perspective of the user or another system.
Example:
HTTP Request
β
Controller
β
Service
β
Repository
β
Database
β
HTTP Response
These tests provide high confidence but are more expensive and slower.
4. Getting Started with Unit Testing in a Spring Boot Application
Spring Boot applications typically use JUnit 5.
A typical Maven project can include:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
spring-boot-starter-test provides several testing libraries, including:
- JUnit 5
- Mockito
- Spring Test
- AssertJ
You normally donβt need to add JUnit and Mockito separately when using this starter.
5. Test Class Structure
Suppose we have:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
A unit test can look like:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
}
Letβs understand these annotations.
6. JUnit 5
JUnit 5 is the testing framework.
A basic test looks like:
@Test
void shouldCalculateTotal() {
int result = 10 + 20;
assertEquals(30, result);
}
The @Test annotation tells JUnit:
This method is a test case.
6.1 Assertions
Assertions verify the expected result.
assertEquals
assertEquals(30, result);
Checks that two values are equal.
assertNotNull
assertNotNull(user);
Checks that an object is not null.
assertNull
assertNull(user);
Checks that an object is null.
assertTrue
assertTrue(user.isActive());
Checks that an expression is true.
assertFalse
assertFalse(user.isDeleted());
Checks that an expression is false.
assertThrows
Very useful when testing exceptions:
assertThrows(
UserNotFoundException.class,
() -> userService.findById(10L)
);
This verifies that the method throws the expected exception.
7. Mockito
Mockito allows us to create mock objects.
Suppose:
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
The UserService depends on UserRepository.
We donβt want to use the real repository during a unit test.
Instead:
@Mock
private UserRepository userRepository;
Mockito creates a fake implementation of the repository.
8. @Mock
@Mock creates a Mockito mock.
@Mock
private UserRepository userRepository;
The mock doesnβt execute the real repository implementation.
For example:
userRepository.findById(1L);
will return null by default unless we configure it.
We can define the behavior:
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
Now Mockito knows:
When
findById(1L)is called, return this user.
9. @InjectMocks
@InjectMocks creates the class under test and injects the mocks into it.
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
Conceptually:
Mockito
UserRepository Mock
β
βΌ
UserService
This allows us to test UserService without creating a real UserRepository.
10. @ExtendWith(MockitoExtension.class)
JUnit needs to know that Mockito should initialize the annotations.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
}
This enables:
@Mock
@InjectMocks
@Spy
@Captor
for the test class.
11. Writing a Complete Unit Test
Letβs create a complete example.
Production code:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
Test:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldReturnUserWhenUserExists() {
User user = new User(1L, "John");
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
User result = userService.findById(1L);
assertNotNull(result);
assertEquals("John", result.getName());
}
}
The flow is:
Arrange
β
Act
β
Assert
This is commonly called the AAA pattern.
12. Arrange, Act, Assert
Arrange
Prepare the test scenario.
User user = new User(1L, "John");
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
Act
Execute the method being tested.
User result = userService.findById(1L);
Assert
Verify the result.
assertNotNull(result);
assertEquals("John", result.getName());
A good unit test usually makes these three phases easy to identify.
13. Testing Exceptions
Suppose the service contains:
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
We can test the exception:
@Test
void shouldThrowExceptionWhenUserDoesNotExist() {
when(userRepository.findById(1L))
.thenReturn(Optional.empty());
assertThrows(
UserNotFoundException.class,
() -> userService.findById(1L)
);
}
We can also capture the exception:
@Test
void shouldThrowExceptionWithCorrectMessage() {
when(userRepository.findById(1L))
.thenReturn(Optional.empty());
UserNotFoundException exception = assertThrows(
UserNotFoundException.class,
() -> userService.findById(1L)
);
assertEquals(
"User 1 was not found",
exception.getMessage()
);
}
14. Mockito when() and thenReturn()
Mockito allows us to define behavior:
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
This means:
When findById(1L) is called
β
Return Optional.of(user)
Another example:
when(userRepository.existsByEmail("john@email.com"))
.thenReturn(true);
15. Testing Save Operations
Suppose we have:
public User createUser(User user) {
if (userRepository.existsByEmail(user.getEmail())) {
throw new EmailAlreadyExistsException();
}
return userRepository.save(user);
}
Test:
@Test
void shouldCreateUser() {
User user = new User(null, "John");
when(userRepository.existsByEmail("john@email.com"))
.thenReturn(false);
when(userRepository.save(user))
.thenReturn(new User(1L, "John"));
User result = userService.createUser(user);
assertNotNull(result);
assertEquals(1L, result.getId());
}
16. Mockito verify()
Sometimes we donβt only care about the return value.
We also want to verify that a dependency was called correctly.
Example:
verify(userRepository).save(user);
This verifies that:
userRepository.save(user)
was actually executed.
16.1 Verify Number of Calls
verify(userRepository, times(1))
.save(user);
We can also verify that something was never called:
verify(userRepository, never())
.save(user);
For example:
@Test
void shouldNotSaveUserWhenEmailAlreadyExists() {
User user = new User(null, "John");
when(userRepository.existsByEmail(user.getEmail()))
.thenReturn(true);
assertThrows(
EmailAlreadyExistsException.class,
() -> userService.createUser(user)
);
verify(userRepository, never())
.save(user);
}
This test verifies an important business rule:
Email exists
β
Exception
β
User is NOT saved
17. Mockito Argument Matchers
Mockito provides argument matchers.
For example:
when(userRepository.findById(anyLong()))
.thenReturn(Optional.of(user));
Common matchers include:
any()
anyString()
anyLong()
anyInt()
eq()
isNull()
isNotNull()
Example:
when(userRepository.findByEmail(anyString()))
.thenReturn(Optional.of(user));
18. eq() Matcher
Suppose we need to match a specific value:
when(userRepository.findByEmail(
eq("john@email.com")
)).thenReturn(Optional.of(user));
Be careful when mixing matchers and raw values.
Incorrect:
when(repository.find("John", anyLong()));
Correct:
when(repository.find(eq("John"), anyLong()));
When using matchers, use matchers for all arguments.
19. Testing Void Methods
Suppose:
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
We can test it with:
@Test
void shouldDeleteUser() {
userService.deleteUser(1L);
verify(userRepository)
.deleteById(1L);
}
Since deleteById() returns nothing, there is no result to assert.
Instead, we verify the interaction.
20. Mockito doThrow()
For void methods, we can configure exceptions using doThrow().
doThrow(new DatabaseException())
.when(userRepository)
.deleteById(1L);
Then:
assertThrows(
DatabaseException.class,
() -> userService.deleteUser(1L)
);
21. Testing Controllers
Unit testing a controller is different from testing the complete HTTP stack.
Example controller:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
A pure unit test can simply instantiate the controller and mock the service.
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
@Test
void shouldReturnUser() {
User user = new User(1L, "John");
when(userService.findById(1L))
.thenReturn(user);
User result = userController.getUser(1L);
assertEquals("John", result.getName());
verify(userService)
.findById(1L);
}
}
This is a unit test because Spring MVC is not running.
22. Unit Test vs @SpringBootTest
One of the most important distinctions in Spring Boot testing is:
@SpringBootTest
versus:
@ExtendWith(MockitoExtension.class)
Unit Test
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
}
Characteristics:
- Does not start Spring
- Very fast
- Uses Mockito
- Dependencies are mocked
- Tests one class in isolation
Spring Boot Integration Test
@SpringBootTest
class UserServiceIntegrationTest {
}
This starts the Spring application context.
Spring creates and wires the beans.
This is useful when we want to test the integration between Spring components.
However, it is generally unnecessary for a simple unit test.
23. When Should You Use @SpringBootTest?
Donβt automatically use:
@SpringBootTest
for every test.
Use it when you actually need the Spring context.
For example:
Testing Spring configuration
Testing bean wiring
Testing multiple layers together
Testing integration with infrastructure
If you only want to test:
UserService
a Mockito-based unit test is usually better.
24. Testing Repository Behavior
Repositories often donβt need extensive unit tests.
For example:
userRepository.findByEmail(...)
If youβre using Spring Data JPA, much of the repository implementation is provided by Spring.
Instead, integration tests can be more valuable when you need to verify:
- Custom queries
- JPQL
- Native queries
- Database mappings
- Relationships
- Constraints
For example:
@DataJpaTest
class UserRepositoryTest {
}
@DataJpaTest is designed for JPA-related tests and is generally more appropriate than @SpringBootTest for this purpose.
25. Test Naming
Good test names explain the expected behavior.
Avoid:
@Test
void testUser() {
}
Prefer:
@Test
void shouldReturnUserWhenUserExists() {
}
Or:
@Test
void shouldThrowExceptionWhenUserDoesNotExist() {
}
Another common style is:
@Test
void findById_whenUserDoesNotExist_shouldThrowException() {
}
The important thing is consistency.
26. Test One Behavior
A test should ideally verify one behavior.
Avoid:
@Test
void testEverything() {
// create user
// update user
// delete user
// search user
// validate email
}
Prefer:
@Test
void shouldCreateUser() {
}
@Test
void shouldUpdateUser() {
}
@Test
void shouldDeleteUser() {
}
@Test
void shouldFindUserById() {
}
This makes failures easier to understand.
27. Donβt Test Implementation Details
A common mistake is testing how the code works instead of what it does.
Suppose:
public User findById(Long id) {
return repository.findById(id)
.orElseThrow(...);
}
The important behavior is:
User exists β return user
User does not exist β throw exception
We should not create tests that are tightly coupled to internal implementation details that may change during refactoring.
28. Avoid Excessive Mocking
Mockito is useful, but donβt mock everything.
Bad example:
Service
β
Mock A
β
Mock B
β
Mock C
β
Mock D
At some point, the test can become disconnected from real application behavior.
Mock dependencies that are external to the unit being tested.
For example:
UserService
β
Mock UserRepository
makes sense.
But if youβre testing a simple domain object:
User user = new User();
there is usually no reason to mock the User.
29. Test Behavior, Not Coverage
Code coverage is useful, but 100% coverage does not automatically mean 100% quality.
For example:
if (user != null) {
return user;
}
A test that simply executes this line may increase coverage.
But a good test verifies meaningful behavior.
The goal should be:
High confidence, not a high percentage.
30. Test Edge Cases
Good unit tests should consider more than the happy path.
For example, instead of only testing:
User exists
also consider:
User does not exist
Email already exists
Invalid input
Null values
Empty collections
Boundary values
Unexpected dependency failures
For example:
@Test
void shouldThrowExceptionWhenUserDoesNotExist() {
}
and:
@Test
void shouldNotCreateUserWhenEmailAlreadyExists() {
}
31. Parameterized Tests
JUnit 5 supports parameterized tests.
Instead of writing:
@Test
void shouldRejectInvalidEmail1() {
}
@Test
void shouldRejectInvalidEmail2() {
}
@Test
void shouldRejectInvalidEmail3() {
}
we can use:
@ParameterizedTest
@ValueSource(strings = {
"invalid",
"test",
"hello"
})
void shouldRejectInvalidEmail(String email) {
assertFalse(
validator.isValid(email)
);
}
This allows the same test logic to run with multiple inputs.
32. Testing with @BeforeEach
JUnit allows common setup before every test.
@BeforeEach
void setUp() {
user = new User(1L, "John");
}
Example:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
private User user;
@BeforeEach
void setUp() {
user = new User(1L, "John");
}
}
Use @BeforeEach when the setup is genuinely shared by multiple tests.
Donβt move everything into @BeforeEach just because you can.
33. @BeforeAll and @AfterAll
JUnit also provides:
@BeforeAll
and:
@AfterAll
@BeforeAll runs once before all tests.
@AfterAll runs once after all tests.
Example:
@BeforeAll
static void setup() {
// runs once
}
These should be used sparingly because shared state can make tests harder to reason about.
34. Testing with AssertJ
Spring Boot Test also includes AssertJ.
Instead of:
assertEquals("John", result.getName());
we can write:
assertThat(result.getName())
.isEqualTo("John");
AssertJ becomes particularly useful for complex assertions.
For example:
assertThat(users)
.hasSize(2)
.extracting(User::getName)
.containsExactly("John", "Mary");
This often makes tests easier to read.
35. Unit Testing a Service with Multiple Dependencies
Suppose:
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public UserService(
UserRepository userRepository,
EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public User createUser(User user) {
User savedUser = userRepository.save(user);
emailService.sendWelcomeEmail(savedUser);
return savedUser;
}
}
The unit test can mock both dependencies:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
}
Then:
@Test
void shouldCreateUserAndSendWelcomeEmail() {
User user = new User(null, "John");
User savedUser = new User(1L, "John");
when(userRepository.save(user))
.thenReturn(savedUser);
User result = userService.createUser(user);
assertEquals(1L, result.getId());
verify(userRepository)
.save(user);
verify(emailService)
.sendWelcomeEmail(savedUser);
}
The real email service is never called.
36. Testing Interaction Order
Sometimes the order of operations matters.
Mockito provides InOrder:
InOrder inOrder = inOrder(
userRepository,
emailService
);
inOrder.verify(userRepository)
.save(user);
inOrder.verify(emailService)
.sendWelcomeEmail(savedUser);
This verifies:
1. Save user
2. Send email
Use this only when the order is actually part of the behavior.
Donβt verify ordering unnecessarily.
37. Best Practices
37.1 Keep Unit Tests Fast
A unit test should generally execute very quickly.
Avoid:
Real database
Real HTTP calls
Real message broker
Real external APIs
Full Spring context
when writing a true unit test.
37.2 Keep Tests Independent
One test should not depend on another test.
Bad:
testCreateUser()
β
testUpdateUser()
β
testDeleteUser()
Tests should be independently executable.
37.3 Donβt Use Shared Mutable State
Avoid having tests modify common objects that other tests depend on.
Prefer creating fresh test data for each test.
37.4 Make Tests Deterministic
A test should produce the same result every time.
Avoid depending on:
Current time
Random numbers
External APIs
Network
Database state
Machine configuration
unless these are explicitly controlled.
For example, instead of:
LocalDateTime.now()
consider injecting a Clock:
Clock clock;
Then the test can control the current time.
38. Follow the AAA Pattern
A clean test usually follows:
Arrange
Act
Assert
Example:
@Test
void shouldReturnUser() {
// Arrange
User user = new User(1L, "John");
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
// Act
User result = userService.findById(1L);
// Assert
assertEquals("John", result.getName());
}
This makes tests easy to read.
39. Use Meaningful Test Data
Avoid meaningless values:
User user = new User(1L, "abc");
Prefer:
User user = new User(1L, "John Smith");
Meaningful data makes the intent of the test clearer.
For larger applications, test-data builders or factories can help:
User user = UserTestDataFactory.validUser();
40. Donβt Overuse verify()
This:
verify(repository).save(user);
is useful when the interaction itself is important.
But avoid verifying every internal call:
verify(repository).findById(id);
verify(repository).save(user);
verify(repository).existsByEmail(email);
verify(validator).validate(user);
verify(mapper).map(user);
This can make tests fragile.
Focus primarily on observable behavior.
41. Donβt Test Framework Behavior
You generally donβt need to test whether Spring Data itself can execute:
repository.findById()
or whether JUnit can execute:
@Test
Test your application logic.
For example:
Good:
"UserService throws UserNotFoundException when user doesn't exist."
Less useful:
"Spring Data repository can execute findById."
42. Unit Test Checklist
When writing a unit test, ask:
Isolation
- Am I testing one unit?
- Are external dependencies mocked?
Behavior
- What behavior am I testing?
- What should happen when it succeeds?
- What should happen when it fails?
Structure
- Is the test easy to understand?
- Does it follow Arrange β Act β Assert?
Edge Cases
- What happens with invalid input?
- What happens when data doesnβt exist?
- What happens when a dependency fails?
Maintainability
- Is the test coupled to implementation details?
- Will the test survive a reasonable refactoring?
43. Example: Complete Service Test
Production code:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(User user) {
if (userRepository.existsByEmail(user.getEmail())) {
throw new EmailAlreadyExistsException(
user.getEmail()
);
}
return userRepository.save(user);
}
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(
() -> new UserNotFoundException(id)
);
}
}
Unit tests:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldCreateUserWhenEmailDoesNotExist() {
User user = new User(
null,
"John",
"john@email.com"
);
User savedUser = new User(
1L,
"John",
"john@email.com"
);
when(userRepository.existsByEmail("john@email.com"))
.thenReturn(false);
when(userRepository.save(user))
.thenReturn(savedUser);
User result = userService.createUser(user);
assertEquals(1L, result.getId());
assertEquals("John", result.getName());
verify(userRepository)
.save(user);
}
@Test
void shouldThrowExceptionWhenEmailAlreadyExists() {
User user = new User(
null,
"John",
"john@email.com"
);
when(userRepository.existsByEmail("john@email.com"))
.thenReturn(true);
assertThrows(
EmailAlreadyExistsException.class,
() -> userService.createUser(user)
);
verify(userRepository, never())
.save(user);
}
@Test
void shouldReturnUserWhenUserExists() {
User user = new User(
1L,
"John",
"john@email.com"
);
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
User result = userService.findById(1L);
assertEquals(1L, result.getId());
assertEquals("John", result.getName());
}
@Test
void shouldThrowExceptionWhenUserDoesNotExist() {
when(userRepository.findById(1L))
.thenReturn(Optional.empty());
assertThrows(
UserNotFoundException.class,
() -> userService.findById(1L)
);
}
}
This gives us four important scenarios:
Create user
β
Email doesn't exist
β
Save user
Create user
β
Email already exists
β
Throw exception
Find user
β
User exists
β
Return user
Find user
β
User doesn't exist
β
Throw exception
44. Final Mental Model
A useful way to think about unit testing in a Spring Boot application is:
UNIT TEST
β
βΌ
Class Under Test
β
ββββββββββ΄βββββββββ
βΌ βΌ
Mock Dependency Mock Dependency
β β
βΌ βΌ
Repository Service/API
JUnit 5 provides the testing framework:
JUnit 5
βββ @Test
βββ @BeforeEach
βββ Assertions
βββ Parameterized Tests
Mockito provides test doubles:
Mockito
βββ @Mock
βββ @InjectMocks
βββ when()
βββ thenReturn()
βββ verify()
βββ Argument Matchers
Spring Boot provides integration-testing support:
Spring Boot Test
βββ @SpringBootTest
βββ @WebMvcTest
βββ @DataJpaTest
The key distinction is:
Unit Test
β
Fast + Isolated + Mock dependencies
Integration Test
β
Multiple components + Real infrastructure
End-to-End Test
β
Complete application flow
The ultimate goal of unit testing is not simply to achieve a high code-coverage percentage.
The goal is to create fast, reliable, maintainable tests that give developers confidence that the applicationβs behavior is correct.