Introduction to Spring Boot Application Testing for Beginners – A Practical Guide (Part 1)

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 first part of a three-part series that provides a practical introduction to testing Spring Boot applications. A social media app I developed serves as an example. The focus is on the post service, which maps the central business logic around posts.

What can the Post Service do?

  • Creators can create, edit, delete and retrieve posts
  • Users may only read posts

Architecture of the application

The application follows a classic layered architecture:

  • RestController – defines the HTTP endpoints
  • Service – contains the business logic
  • Repository – communicates with the database

System overview

The overall system consists of three main components:

  • API Gateway
  • Keycloak + Identity Provider (IDP)
  • REST-API (Social Media App)

Focus of this section: Web layer tests

This section centres on testing the web layer, in particular the RestController. The following code snippet shows an excerpt from the PostController and the PostService:

// PostController.java

// ... other Imports and Code ...

@Autowired
private PostService postService;

@Autowired
private Utils utils;

@PreAuthorize("hasAnyAuthority('ROLE_CREATOR', 'ROLE_ADMIN')")
@PostMapping(
    produces = MediaType.APPLICATION_JSON_VALUE,
    consumes = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<?> create(
        JwtAuthenticationToken jwtAuthenticationToken,
        @RequestBody PostDTO postDTO
) {
    return utils.fold201(
        postService.save(postDTO, jwtAuthenticationToken)
            .map(post -> postService.modelToSingleResponseDTO(post))
    );
}

// ... other Code ...
// PostService.java

// ... other Imports und Code ...

@Transactional
public Either<ErrorJson, Post> save(PostDTO postDTO, JwtAuthenticationToken jwtAuthenticationToken) {
    return userService.getUserFromToken(jwtAuthenticationToken)
            .flatMap(user -> 
                utils.wrapCall(
                    () -> postRepository.persist(DTOToModel(postDTO, user)),
                    new ErrorUnableToSaveToDB("post")
                )
            );
}

// ... other Code ...


Structure of the test class

Before we look at the test cases in detail, we will first define the basic concept of our test class.

We follow a layered test approach that is orientated towards the architecture of the application. In this section, we focus on unit tests of the controller. Integration tests will follow later.

Especially with more complex applications, it is often impractical to test the controllers completely isolated from their dependencies. However, as the focus here is clearly on the behaviour of the controller, it is justified from a pragmatic point of view to continue to consider these tests as unit tests.

Test context and annotations

Spring loads an ApplicationContext for each test run, which can be time-consuming. It therefore makes sense:

  • Have as few different test contexts as possible → Use caching effects
  • Keep the contexts as small as possible → faster tests

Since we only want to test the web layer at this point (not the entire application), we use a slice test with:

@WebMvcTest(controllers = PostController.class)
public class PostControllerTest {}

Why not @SpringBootTest?

@SpringBootTest
@AutoConfigureMockMvc
public class PostControllerTest {}

This combination loads the entire ApplicationContext, including database, services, repositories etc. – which is unnecessary and inefficient if only a single controller is to be tested. @WebMvcTest is much leaner and more targeted here.

Regardless of whether we use @SpringBootTest or @WebMvcTest, no real web server is started during the test runs. The explicit use of @SpringBootTest is an exception: @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT). In this case, a web server is actually started – which can be helpful in certain debugging scenarios. However, this is not necessary for our purposes.

Although no web server is running, we need a tool to send HTTP requests to our controller. This is where MockMvc or from Spring Boot 3.4 the MockMvcTester comes into play. These tools make it possible to simulate HTTP requests as if they were arriving via the network. The responses can then be checked using Hamcrest (classic) or AssertJ (from Spring Boot 3.4).

Test of the web layer

The tests focus on the external interface of our application – the web layer, specifically the PostController. But why is it even worth testing this layer separately? In many cases, there is a risk that security rules, routing errors or simple annotations such as @PreAuthorise, @RequestBody, @PostMapping etc. are implicitly assumed to work. In reality, however, it is often the case that:

  • Access rights are inadvertently set incorrectly (ROLE_USER instead of ROLE_CREATOR)
  • Security concepts such as CSRF or JWT verification are misconfigured
  • Controllers react too tolerantly or too restrictively to requests

With web layer tests like this one, we have the opportunity to recognise these problems early on in the development process. And additionally because the test does not load the entire application, but only focuses on the controller (@WebMvcTest), it is lightweight and very fast.

We can therefore summarise – these tests are valuable in the context of:

  • Security & authorisation → Do we make sure that only certain roles really have access?
  • Request structure & validation → How does the endpoint behave if fields are missing, the format is incorrect or the content type is invalid?
  • Error tolerance & response handling → Is an error handled correctly (e.g. 403 Forbidden, 400 Bad Request, 415 Unsupported Media Type)?

These tests are no substitute for integration tests – but they provide a high level of security with little effort.

@WebMvcTest(controllers = PostController.class)
@Import(SecurityConfig.class)
class PostControllerTest {
    @Autowired
    private MockMvcTester mockMvc;

    @MockitoBean
    private PostService postService;

    @TestConfiguration
    static class TestConfig {
        @Bean
        public Utils utils() {
            return new Utils();
        }
    }
    // ... other Code ...
}

If we look at the controller shown at the beginning, we recognise two dependencies: the PostService and the Utils class.

Since we are not testing the service itself, but only the controller, we need to mock the PostService. This is done using the annotation @MockitoBean, whereby Spring automatically registers a mock object and injects it into the controller.

We could also have mocked the Utils class in this way. In this case, however, I decided to bring the real implementation into the test context. To do this, we use a @TestConfiguration in which the Utils instance is defined as a bean. This configuration is read in when the test is started and the bean is automatically registered. As the Utils class is @Autowired in the controller, it is injected correctly.

Another important point: In the controller, we secure the endpoints using @PreAuthorise. In order for this access control to work correctly in the test environment, our own SecurityConfig must be explicitly imported into the slice test. Otherwise, Spring will not load a complete security configuration as part of @WebMvcTest, which can lead to unexpected behaviour – such as failed authorisations despite a correct setup.

Brief overview: Authentication and authorisation flow

  • The user first authenticates himself via Keycloak
  • After successful login, the user receives a bearer token (JWT)
  • Our SecurityConfig defines how the JWT is processed: We extract the user role from the resource_access.roles claim
  • Depending on this role (e.g. ROLE_ADMIN, ROLE_CREATOR, ROLE_USER), Spring Security decides whether a specific controller endpoint may be called
  • The resulting JwtAuthenticationToken is automatically available as a parameter in the controller and contains all relevant information about the user

Test cases

Test case: Successful creation of a post

Let’s now look at the first test case. This tests the successful creation of a post via the corresponding POST endpoint.

@WebMvcTest(controllers = PostController.class)
@Import(SecurityConfig.class)
class PostControllerTest {

    @Autowired
    private MockMvcTester mockMvc;

    @MockitoBean
    private PostService postService;

    @TestConfiguration
    static class TestConfig {
        @Bean
        public Utils utils() {
            return new Utils();
        }
    }

    @Test
    @DisplayName("Successfully: POST with ROLE_CREATOR")
    void createPost_successfulAsCreator() throws Exception {
        UUID userId = UUID.randomUUID();
        UUID postId = UUID.randomUUID();
        Post mockPost = createMockPost(postId, createMockUser(userId));

        when(postService.save(
                ArgumentMatchers.any(PostDTO.class),
                ArgumentMatchers.any(JwtAuthenticationToken.class))
        ).thenReturn(Either.right(mockPost));

        when(postService.modelToSingleResponseDTO(
                ArgumentMatchers.any(Post.class))
        ).thenAnswer(invocation -> {
            Post post = invocation.getArgument(0);
            return createResourceResponse(post);
        });
        
        mockMvc.post()
                .uri("/posts")
                .content(buildPostRequest().toString())
                .contentType(MediaType.APPLICATION_JSON)
                .with(csrf())
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_CREATOR")))
                .exchange()
                .assertThat()
                .hasStatus(HttpStatus.CREATED)
                .hasContentType(MediaType.APPLICATION_JSON)
                .bodyJson()
                .hasPathSatisfying("$.data.item.title", path ->
                        path.assertThat().isEqualTo("Mock Title"))
                .hasPathSatisfying("$.data.item.description", path ->
                        path.assertThat().isEqualTo("Description"));
    }

    // ... other tests or helper methods...
}

When the endpoint is called, the save method of the PostService is first called in the controller. If successful, this returns a Post object. Afterwards, the modelToSingleResponseDTO method is called in the service to transform the returned Post object into a response DTO.

Since we mocked the PostService in the test, these methods would return null by default – or even throw a NullPointerException if their return values are reused. To avoid this, we need to explicitly tell Mockito what should happen when these methods are called.

1. postService.save(…)
Here we define that a call with any arguments (any(PostDTO.class) and any(JwtAuthenticationToken.class)) returns a prepared mockPost:

   when(postService.save(...)).thenReturn(Either.right(mockPost));

2. postService.modelToSingleResponseDTO(…)
This method is then called with the result of the save call (i.e. the mockPost). We use thenAnswer(…) instead of thenReturn(…), as we want to dynamically read the transferred post from the mock service in order to generate a DTO from it.

when(postService.modelToSingleResponseDTO(any(Post.class)))
  .thenAnswer(invocation -> {
      Post post = invocation.getArgument(0);
      return createResourceResponse(post);
  });

This ensures that the controller runs through its logic correctly and that a fully constructed response object is generated.

csrf()
As Spring Security activates CSRF protection by default, we must explicitly include a CSRF token in the test for state-changing HTTP requests such as POST, PUT or DELETE. If this is omitted, Spring rejects the request with a 403 Forbidden – even if the authentication is correct. The .with(csrf()) method ensures that a valid CSRF token is simulated and sent in the test.

jwt()
As our endpoints are secured with @PreAuthorise, Spring Security expects an authenticated context. We use .with(jwt()) to simulate a valid JWT-based login. In addition, we can use .authorities(…) to assign specific roles in order to test various access scenarios such as ROLE_USER, ROLE_CREATOR or ROLE_ADMIN.

3. assertThat()
The assertThat() method returns an MvcTestResultAssert object, which we can then use to formulate our assertions. The MockMvcTester API offers a more modern and much more readable syntax than the classic MockMvc, which relies on many static methods and a chained .andExpect(…) structure.

 // example: mockMvc with hamcrest

mockMvc.perform(post("/posts")
        .with(csrf())
        .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_CREATOR")))
        .content(buildPostRequest().toString())
        .contentType(MediaType.APPLICATION_JSON))
    .andExpect(status().isCreated())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
    .andExpect(jsonPath("$.data.item.title").value("Mock Title"))
    .andExpect(jsonPath("$.data.item.description").value("Description"));

// example: mockMvcTester with assertJ
mockMvc.post()
       .uri("/posts")
       .content(buildPostRequest().toString())
       .contentType(MediaType.APPLICATION_JSON)
       .with(csrf())
       .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_CREATOR")))
       .exchange()
       .assertThat()
       .hasStatus(HttpStatus.CREATED)
       .hasContentType(MediaType.APPLICATION_JSON)
       .bodyJson()
       .hasPathSatisfying(
            "$.data.item.title",
             path ->path.assertThat().isEqualTo("Mock Title")
        )
       .hasPathSatisfying(
            "$.data.item.description",
            path -> path.assertThat().isEqualTo("Description")
        );

Further test cases incorrect and unauthorised access

In addition to the successful happy path test, it is also essential to cover error scenarios. These negative test cases ensure that our application behaves correctly even if something goes wrong or is not permitted. They make a significant contribution to safeguarding the API – especially in the areas of security and validation.

We will test three typical problem cases below:

Access with insufficient role (403 Forbidden)

@Test
@DisplayName("FAILURE: ROLE_USER is not allowed to create a post")
void createPost_forbiddenForUserRole() throws Exception {
    mockMvc.post()
            .uri("/posts")
            .content(buildPostRequest().toString())
            .contentType(MediaType.APPLICATION_JSON)
            .with(csrf())
            .with(jwt().authorities(
                new SimpleGrantedAuthority("ROLE_USER")
             ))
            .exchange()
            .assertThat()
            .hasStatus(HttpStatus.FORBIDDEN);
}

In this test, a user with the ROLE_USER role is simulated. As only ROLE_CREATOR and ROLE_ADMIN may have access to the endpoint (see @PreAuthorise), access is rightly blocked with a 403 Forbidden.

No token available (401 Unauthorised)

@Test
@DisplayName("No Token → 401 Unauthorized")
void createPost_unauthorizedWithoutToken() throws Exception {
    mockMvc.post()
            .uri("/posts")
            .content(buildPostRequest().toString())
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .exchange()
            .assertThat()
            .hasStatus(HttpStatus.UNAUTHORIZED);
}

A request is sent here without authentication (no JWT). However, the Spring security configuration requires that only authenticated users have access. Therefore, as expected, we receive the HTTP status 401 Unauthorised.

Incorrect content type (415 Unsupported Media Type)

@Test
@DisplayName("Wrong Content-Type → 415 Unsupported Media Type")
void createPost_unsupportedMediaType() throws Exception {
    mockMvc.post()
            .uri("/posts")
            .content("test=1234")
            .contentType(MediaType.TEXT_PLAIN)
            .with(csrf())
            .with(jwt().authorities(
                new SimpleGrantedAuthority("ROLE_CREATOR")
             ))
            .exchange()
            .assertThat()
            .hasStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
}

In this case, a text/plain request is sent to a JSON endpoint. As the controller explicitly expects application/json (via @PostMapping(…, consumes = MediaType.APPLICATION_JSON_VALUE)),
Spring rejects the request with 415 Unsupported Media Type.

Conclusion

In this part of the series, we showed how the web layer of a Spring Boot application can be tested in isolation using so-called slice tests. Through the targeted use of @WebMvcTest, we were able to focus exclusively on the controller level – without loading the complete ApplicationContext or other layers.

In the next part of the series, we will look at the service layer. There we will learn how we can test the business logic. In the final third part, we will then take a look at integration tests for the repository layer, in which we simulate a real database environment using test containers.

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.


If you find a mistake, have suggestions or simply want to give feedback on my blog – I’m always happy to hear from you! Feel free to write to me, I’m open to any feedback. 😊
You can contact me at: blog.giuseppe.clinaz@gmail.com or via LinkedIn.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *