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.
Please note: The following code example for authorization is for demonstration purposes only, interceptors are not ideally suited as a security layer due to the potential for a mismatch with annotated controller path matching. It's better to use Spring Security for these checks.
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @RestController @RequestMapping("/webhooks") public class WebhookController { private static final Logger LOG = LoggerFactory.getLogger(WebhookController.class); @PostMapping("/orders") public ResponseEntity<Void> handleOrderWebhook(@RequestBody ObjectNode objectNode) { LOG.info("Incoming webhook payload for orders: '{}'", objectNode); // further order handling return ResponseEntity.noContent().build(); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 | @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests( authorizeRequests -> authorizeRequests.mvcMatchers(HttpMethod.POST, "/webhooks/orders").permitAll() .anyRequest().authenticated() ) .csrf().disable(); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public class WebhookAuthorizationHandlerInterceptor implements HandlerInterceptor { private final String validApiKey; private static final Logger LOG = LoggerFactory.getLogger(WebhookAuthorizationHandlerInterceptor.class); public WebhookAuthorizationHandlerInterceptor(String validApiKey) { this.validApiKey = validApiKey; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String apiKey = request.getHeader("X-API-KEY"); LOG.debug("Incoming X-API-KEY header for accessing a webhook: '{}'", apiKey); if (validApiKey.equalsIgnoreCase(apiKey)) { return true; } else { LOG.warn("Invalid API key in the request when trying to access webhooks: '{}'", apiKey); response.sendError(403); // you may also argue to return 401 here return false; } } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @Configuration public class WebMvcConfig implements WebMvcConfigurer { private final String validApiKey; public WebMvcConfig(@Value("${valid-api-key}") String validApiKey) { this.validApiKey = validApiKey; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new WebhookAuthorizationHandlerInterceptor(validApiKey)) .addPathPatterns("/webhooks/**"); } } |
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 MockHttpServletRequest
and MockHttpServletResponse
. As our implementation doesn't depend on any information about the controller handler, we can safely pass null
here:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class WebhookAuthorizationHandlerInterceptorTest { private WebhookAuthorizationHandlerInterceptor cut; private final static String VALID_TEST_API_KEY = "test400"; @BeforeEach void setUp() { this.cut = new WebhookAuthorizationHandlerInterceptor(VALID_TEST_API_KEY); } @Test void shouldBlockRequestWithWhenHeaderIsMissing() throws Exception { HttpServletRequest httpServletRequest = new MockHttpServletRequest(); HttpServletResponse httpServletResponse = new MockHttpServletResponse(); boolean result = cut.preHandle(httpServletRequest, httpServletResponse, null); assertThat(result) .isFalse(); assertThat(httpServletResponse.getStatus()) .isEqualTo(401); } } |
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
orFilter
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @WebMvcTest(value = WebhookController.class, properties = "valid-api-key=test42") class WebhookControllerTest { @Autowired private MockMvc mockMvc; @Test void shouldForbidAccessWithMissingApiKey() throws Exception{ this.mockMvc .perform(post("/webhooks/orders") .contentType(APPLICATION_JSON) .content(""" { "orderId": 42 } """) ) .andExpect(status().isForbidden()); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @Test void shouldForbidAccessWithInvalidApiKey() throws Exception{ this.mockMvc .perform(post("/webhooks/orders") .header("X-API-KEY", "invalid42") .contentType(APPLICATION_JSON) .content(""" { "orderId": 42 } """) ) .andExpect(status().isForbidden()); } |
What's left is to add a test that verifies the happy path by passing a valid API key and expecting the
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | @WebMvcTest(value = WebhookController.class, properties = "valid-api-key=test42") class WebhookControllerTest { @Autowired private MockMvc mockMvc; // ... @Test void shouldAllowAccessWithValidApiKey() throws Exception { this.mockMvc .perform(post("/webhooks/orders") .header("X-API-KEY", "test42") .contentType(APPLICATION_JSON) .content(""" { "orderId": 42 } """) ) .andExpect(status().isNoContent()); } } |
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:
- Guide to Testing With the Spring Boot Starter Test
- Spring Boot Test Slices: Overview and Usage
- Spring Boot Unit and Integration Testing Overview
The source code for this article is available on GitHub.
Joyful testing,
Philip