Spring Boot Test Spring Web MVC HandlerInterceptor

Last Updated:  May 10, 2022 | Published: May 9, 2022

This article showcases how to test a Spring Web MVC HandlerInterceptor using JUnit 5 and Spring Boot. We'll discuss the value of unit tests to test a HandlerInterceptor as well as using  a sliced application context and MockMvc. We will test a HandlerInterceptor with Spring Boot that secures a webhook endpoint by verifying a given API key from a use case perspective.

Spring Boot with Spring Web MVC Project Setup

For demonstration purposes, we use an almost empty Spring Boot Java 17 project. The only dependencies we rely on are the Spring Boot Starter Web, the Spring Boot Starter Security, and the Spring Boot Starter Test (aka. the testing swiss army knife).

Let's assume our application exposes a webhook endpoint that gets called by a remote system. This remote system might be an e-commerce shop that notifies us whenever there's a new order:

Our endpoint has not much business logic to perform and only logs any incoming HTTP POST payload for /webhooks/orders.

What's next is to expose this endpoint publicly. We use the following Spring Security configuration to protect all our HTTP endpoints except the endpoint for receiving the order webhook payload:

The way most webhook mechanisms ensure integrity and origin is either via a custom API key or by validating the signature of the payload.

Let's use the first approach and add an additional authentication and authorization layer. We're going to protect this webhook endpoint so that only requests that contain a specific HTTP header with a valid API key are allowed.

Use Case: Authorization Check With the HandlerInterceptor

We could directly add the authorization check to the controller. However, if we have multiple endpoints that depend on the same authorization check, we end up copy-pasting our security check (… and might even forget it for a new endpoint).

A better approach is to outsource this cross-cutting security concern. One way of implementing this with Spring Web MVC is by implementing a custom HandlerInterceptor.

As a short recap for the HandlerInterceptor vs. Filter debate:

  • a HandlerInterceptor sits between the dispatcher servlet and the controller and is Spring specific. It has access to the controller handler as well as the request and the response. Typical use cases are fine-grained handler-related preprocessing tasks like authorization
  • a Filter sits in front of the dispatcher servlet is part of the Servlet API specification. It has access to the filter chain as well as the request and response. Typical use cases are logging, data compression, auditing, encryption, and authentication
  • Find more information on their differences as part of this Stack Overflow question

Let's implement such a custom HandlerInterceptor that verifies if the X-API-KEY header of the HTTP request is valid:

As part of the preHandle lifecycle, which is called before invoking the actual controller, we perform our additional authorization check. We extract the header value and compare it with a given valid API key. If both keys match, the request can proceed. Otherwise, we fail the request.

The HandlerInterceptor interface comes with two additional methods that we can implement: postHandle and afterCompletion. For this webhook protection use case, there's no need to implement them.

What's left is to configure the path(s) for which Spring will execute this interceptor. We glue things together by implementing the WebMvcConfigurer interface and its addInterceptors method:

This declarative code configuration places our custom interceptor in front of all controllers that handle requests for /webhooks/**. We inject a configuration value to allow an externalized configuration of the valid API key that we expect all webhook requests to contain.

Let's see how we can test and verify this custom authorization check.

Limits of Unit Testing the HandlerInterceptor

As this additional security check is outsourced to a single class with almost no external dependency (except the valid API key), this should be an excellent candidate for unit testing.

We can thoroughly test the various execution paths for our preHandle method to verify how our interceptor reacts to:

  • a missing HTTP header
  • an invalid API key
  • the happy path and a valid API key
  • etc.

When testing the preHandle method with a unit test, we have to find a solution to what types of objects we pass to this method. It expects the HttpServletRequest, HttpServletResponse, and the controller handler.

We could mock all three objects, but this can result in a mocking hell, especially if we chain the access to various objects of the HttpServletRequest or the HttpServletResponse. Both are interfaces, and the actual implementation is part of the Tomcat source code (ApplicationHttpRequest). We can't instantiate this class as it is encapsulated.

We would also break one of the golden Mockito rules when mocking these objects as we would mock types we don't own.

A better approach is to use Spring's MockHttpServletRequestand MockHttpServletResponse. As our implementation doesn't depend on any information about the controller handler, we can safely pass null here:

These two fake implementations from Spring help us test our interceptor in isolation without any Mockito usage.

Nevertheless, we can never verify an actual request coming through or being blocked with such unit tests. Fortunately, Spring Boot got us covered.

A Better Approach Using Spring Boot's @WebMvcTest

With the unit tests, we have a good place to start verifying our custom HandlerInterceptor. However, with these tests, we quickly reach limits as we can't verify the following essential aspects:

  • Is the HandlerInterceptor configured and invoked for our target path(s)?
  • Will we be able to access the planned headers, or is something else removing/modifying them before?
  • Will there be any previous HandlerInterceptor or Filter in the chain that can cannibalize the request?

These questions go more into the integration testing area and are also close to the boundary of our framework (which we, of course, do not want to test excessively).

There are still many reasons to verify our security check with a sliced Spring context test setup. The @WebMvcTest annotation populates a minimal Spring TestContext that includes all relevant Spring Web MVC beans and configuration. This includes our security configuration (WebSecurityConfig) as well as our WebSecurityConfig.

We won't be using real HTTP communication and won't start the embedded servlet container (Tomcat in our case) and instead use MockMvc to interact with a mocked servlet environment.

Furthermore, this approach gives us HTTP semantics, and we can verify and test requests going through the chain of filter/interceptors and verify the response of our controller.

The test above creates such a sliced Spring context test for our WebhookController. As part of the @WebMvcTest annotation, we configure the valid API key. We then inject the auto-configured MockMvc instance and write the first test to ensure we reject the request if the header is missing.

Next, we can test the scenario for handling an invalid API key:

What's left is to add a test that verifies the happy path by passing a valid API key and expecting the

Summary

These tests give us additional safety and confidence that our handler interceptor authorizes only valid requests. Depending on the complexity of the HandlerInterceptor we may test all code execution paths with unit tests and then only verify the main happy and error paths with a sliced context test. This way, we avoid having too much test duplication.

We could even go a step further and use @SpringBootTest to write a final integration test that uses real HTTP communication. Remember, with MockMvc, we only test against a mocked Servlet environment.

For more content about testing Spring Boot applications, start with the following articles:

The source code for this article is available on GitHub.

Joyful testing,

Philip

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
>