To make the whole thing more practical, I have prepared a very simplified example application based on Spring Boot. So you can follow every step of the series hands-on and test it directly yourself.
This is the second part of the three-part blog series. The first part is not essential for understanding this post, but is highly recommended as it provides a better overall understanding, practical examples of web layer testing and a brief overview of the application architecture.
As discussed there, we focus on slice tests to specifically test individual layers of our architecture. These do not replace integration tests – rather, they extend our test coverage in a targeted and efficient way.
Focus of this section: Service layer test
The service layer forms the centrepiece of an application’s business logic. To test it specifically, let’s take a look at the update() method, which can be used to update an existing post.
The update() method has three parameters:
- UUID id – identifies the post that is to be updated
- UpdatePostDTO – holds the new data for the post
- JwtAuthenticationToken – stands for the currently authenticated use
Functionality of the update() method:
- The author of the post is determined using the ID of the post
- It is checked whether the current user is either the author himself or an admin
- The post is overwritten with the new data if the authorisation check was successful
@Transactional
@Service
class PostService {
// PostService.java
// ... other Imports und Code ...
@Autowired
UserService userService;
@Autowired
PostRepository postRepository;
@Autowired
Utils utils;
public Either<ErrorJson, Post> update(
UUID id,
UpdatePostDTO updatePostDTO,
JwtAuthenticationToken jwtAuthenticationToken) {
return utils.wrapCall(
() -> postRepository.findCreatorByPostId(id).orElseThrow(),
new ErrorNotFoundInDB("post", updatePostDTO.getId())
)
.flatMap(creator -> userService.getAuthorizedCreator(
jwtAuthenticationToken, creator.getId()
))
.flatMap(user -> utils.wrapCall(
() -> postRepository.update(updateDtoToModel(
id,
updatePostDTO,
user)),
new ErrorUnableToSaveToDB("post")
));
}
// ... other Code ...
}
Structure of the test class
Before we dive into the individual test cases, let’s clarify the basic concept of our test class.
We follow an architecture-oriented slice test approach in which each application layer is tested separately. In this section, we focus on isolated unit tests of the service layer. Integration tests will follow in a later part.
In contrast to the web layer test from part 1, here we test completely independently of Spring Boot-specific components. Why is this possible? Because the service layer generally does not require any in-depth Spring abstractions. Although we use @Autowired, we can easily mock the dependencies with Mockito.
Mockito-Integration mit @ExtendWith
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
...
}
This allows us to add @Mock annotation to fields (instead of mocking manually) and @InjectMocks ensures that Mockito automatically injects the dependencies. It should be noted that, depending on your Spring Boot version, the syntax.
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
@Mock
private UserService userService;
@Mock
private Utils utils;
@InjectMocks
private PostService postService;
}
Preparation of the test with lifecycle annotations
In our tests, we need a JwtAuthenticationToken, among other things. As this is not part of the logic to be tested, we can simply mock it. We use @BeforeAll in combination with mock() for this. (It should be noted here that the JWT could also be mocked using @Mock. The Lifecylce method is used to display various functionalities.)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
@Mock
private UserService userService;
@Mock
private Utils utils;
@InjectMocks
private PostService postService;
private JwtAuthenticationToken token;
@BeforeAll
void setup() {
token = mock(JwtAuthenticationToken.class);
}
}
Normally, the method annotated with @BeforeAll should be static. However, thanks to the annotation @TestInstance(TestInstance.Lifecycle.PER_CLASS), it can also be non-static.
Insert: JUnit 5 lifecycle annotations at a glance
Annotation | When is it called? | How often? |
@BeforeEach |individual test | Before each | Per test |
@AfterEach | After each individual test | Per test |
@BeforeAll | Once before all tests in the class | 1x |
@AfterAll | Once after all tests in the class | 1x |
Test cases
Test case: Successful update of a post
In this test case, we check whether the update() method works correctly in a positive test case. And this is the case if the post exists, the user is authorised and the update is successfully carried out in the database.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
@Mock
private UserService userService;
@Mock
private Utils utils;
@InjectMocks
private PostService postService;
private JwtAuthenticationToken token;
@BeforeAll
void setup() {
token = mock(JwtAuthenticationToken.class);
}
@Test
@DisplayName("Should update post successfully")
void shouldUpdatePost_whenUserIsAuthorized_andPostExists() {
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.right(mockUser))
.thenReturn(Either.right(mockPost));
when(userService.getAuthorizedCreator(token, mockUser.getId()))
.thenReturn(Either.right(mockUser));
Either<ErrorJson, Post> result = postService.update(postId, dto, token);
verify(userService).getAuthorizedCreator(token, mockUser.getId());
verify(utils, times(2))
.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class));
assertThat(result.isRight()).isTrue();
assertThat(result.get().getId()).isEqualTo(postId);
}
}
Analyse the update() method
The method to be tested is located in the PostService.
public Either<ErrorJson, Post> update(
UUID id,
UpdatePostDTO updatePostDTO,
JwtAuthenticationToken jwtAuthenticationToken) {
return utils.wrapCall(
() -> postRepository.findCreatorByPostId(id).orElseThrow(),
new ErrorNotFoundInDB("post", updatePostDTO.getId())
)
.flatMap(creator -> userService.getAuthorizedCreator(
jwtAuthenticationToken, creator.getId()
))
.flatMap(user -> utils.wrapCall(
() -> postRepository.update(updateDtoToModel(
id,
updatePostDTO,
user)),
new ErrorUnableToSaveToDB("post")
));
}
Step 1: Mock findCreatorByPostId
utils.wrapCall(
() -> postRepository.findCreatorByPostId(id).orElseThrow(),
new ErrorNotFoundInDB("post", updatePostDTO.getId())
)
In the test, this call is mocked as follows.
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.right(mockUser))
This means that the first time wrapCall is called, a mockUser is returned – the author of the post.
Step 2: Authorisation check
The method checks whether the user is authorised to make changes:
.flatMap(creator -> userService.getAuthorizedCreator(
jwtAuthenticationToken,
creator.getId()
))
The matching mock definition.
when(userService.getAuthorizedCreator(token, mockUser.getId()))
.thenReturn(Either.right(mockUser));
The user is authorised – we are simulating the successful case here.
Step 3: Update in the database
The actual saving of the updated post looks like this.
.flatMap(user -> utils.wrapCall(
() -> postRepository.update(updateDtoToModel(id, updatePostDTO, user)),
new ErrorUnableToSaveToDB("post")
));
In the test, this is also mapped using wrapCall. As wrapCall was already used for the first step, we specify two returns in succession:
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.right(mockUser))
.thenReturn(Either.right(mockPost));
Schritt 4: Assertions and verifications
At the end, we check whether our expectations have been met – both in terms of content and methodological execution:
verify(userService).getAuthorizedCreator(token, mockUser.getId());
verify(utils, times(2))
.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class));
assertThat(result.isRight()).isTrue();
assertThat(result.get().getId()).isEqualTo(postId);
The assertions ensure that:
- The correct path has been traversed (mocks have been called)
- The result is a successful -> Either.right(Post)
- The returned post ID matches the expected one
Other test cases: negative
In addition to successfully testing the happy path, it is also important to cover error scenarios. These negative test cases ensure that our application behaves correctly even if something goes wrong or is not permitted.
We will test three typical problem cases below:
Test case: Post does not exist
In this test, we simulate the case where the post for the specified ID is not found in the database. In this case, the wrapCall() method returns an Either.left with a suitable ErrorJson.
We expect that:
- No access to userService takes place, as the process is cancelled beforehand
- The error is correctly returned to the caller
@Test
@DisplayName("Should return error if post does not exist")
public void shouldReturnError_whenPostDoesNotExist() {
ErrorJson notFound = new ErrorJson("Not Found", "Post not found", 404);
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.left(notFound));
Either<ErrorJson, Post> result = postService.update(postId, dto, token);
verify(utils)
.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class));
verifyNoInteractions(userService);
assertThat(result.isLeft()).isTrue();
assertThat(result.getLeft().getStatus()).isEqualTo(404);
}
Test case: User is not authorised
This checks what happens if the current user is not authorised to edit the post. In this case too, the getAuthorisedCreator() method returns an Either.left with an error object.
We make sure that:
- The authorisation step is executed
- But no attempt is made to save the post
- A corresponding 403 error is returned
@Test
@DisplayName("Should return error if user not authorized")
public void shouldReturnError_whenUserIsNotAuthorized() {
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.right(mockUser));
ErrorJson unauthorized = new ErrorJson("Access Denied", "You do not have permission", 403);
when(userService.getAuthorizedCreator(token, mockUser.getId()))
.thenReturn(Either.left(unauthorized));
Either<ErrorJson, Post> result = postService.update(postId, dto, token);
verify(utils).wrapCall(any(CheckedFunction0.class), any(ErrorJson.class));
verify(userService).getAuthorizedCreator(token, mockUser.getId());
assertThat(result.isLeft()).isTrue();
assertThat(result.getLeft().getStatus()).isEqualTo(403);
}
Test case: Database error when saving
Even if all the previous steps are successful, saving to the database can still fail. This test simulates exactly this case: wrapCall() during the save process returns an Either.left with a DB-specific error.
We check that:
- All steps are completed
- The error from the last wrapCall() is correctly propagated to the caller
@Test
@DisplayName("Should return error if DB update fails")
public void shouldReturnError_whenUpdateFailsDueToDatabaseError() {
ErrorJson dbError = new ErrorJson("DB Error", "Could not save post", 500);
when(utils.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class)))
.thenReturn(Either.right(mockUser))
.thenReturn(Either.left(dbError));
when(userService.getAuthorizedCreator(token, mockUser.getId()))
.thenReturn(Either.right(mockUser));
Either<ErrorJson, Post> result = postService.update(postId, dto, token);
verify(utils, times(2))
.wrapCall(any(CheckedFunction0.class), any(ErrorJson.class));
verify(userService).getAuthorizedCreator(token, mockUser.getId());
assertThat(result.isLeft()).isTrue();
assertThat(result.getLeft().getStatus()).isEqualTo(500);
}
Conclusion
In this section, we have shown how the service layer of a multi-tier Spring application can be tested. In doing so, we deliberately avoided Spring-specific features. The result: lean, high-performance and easy-to-read unit tests.
By using Mockito, we were able to easily mock external dependencies and test the service logic in isolation. AssertJ was used to verify the results.
In the next part of the series, we will learn how we can use test containers to test the repository layer of our Spring Boot application against a real database.
Leave a Reply