When developing Spring Boot web applications, filters are essential for implementing cross-cutting concerns like rate limiting, authentication, or logging.
While it’s common to test filters with plain unit tests and mocked dependencies, this approach often fails to capture how filters interact with Spring’s request processing pipeline.
This article explores a better way to test Spring MVC filters using the @WebMvcTest
annotation, providing more realistic and comprehensive validation.
Understanding Spring MVC Filters
Before diving into testing approaches, let’s understand what we’re testing. A Spring MVC filter intercepts HTTP requests before they reach your controllers or responses before they return to the client. Here’s a simple rate-limiting filter that we’ll use throughout this article:
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 27 28 |
@Component public class RateLimitingFilter extends OncePerRequestFilter { private final Map<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>(); private final int maxRequestsPerMinute; public RateLimitingFilter(@Value("${rate.limit.max:30}") int maxRequestsPerMinute) { this.maxRequestsPerMinute = maxRequestsPerMinute; } @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String clientIp = request.getRemoteAddr(); AtomicInteger count = requestCounts.computeIfAbsent(clientIp, k -> new AtomicInteger(0)); if (count.incrementAndGet() > maxRequestsPerMinute) { response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS); response.getWriter().write("Rate limit exceeded"); return; } filterChain.doFilter(request, response); } } |
This filter maintains a count of requests from each client IP address and rejects requests that exceed a configurable limit. The filter extends Spring’s OncePerRequestFilter
, which ensures it’s applied only once per request, even in complex request dispatching scenarios.
The logic is straightforward:
- Extract the client’s IP address
- Increment the request count for that IP
- If the count exceeds the limit, return a 429 (Too Many Requests) status
- Otherwise, continue the filter chain
The Limitations of Plain Unit Tests for Filters
A traditional approach to testing this filter would use unit tests with Mockito to mock the request, response, and filter chain. Let’s look at how that would work:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@ExtendWith(MockitoExtension.class) class RateLimitingFilterUnitTest { private RateLimitingFilter filter; @Mock private HttpServletRequest request; @Mock private HttpServletResponse response; @Mock private FilterChain filterChain; |
First, we set up the test class with mocked dependencies for the filter’s inputs. Next, we initialize the filter and configure basic mock behavior:
1 2 3 4 5 6 |
@BeforeEach void setUp() throws IOException { filter = new RateLimitingFilter(2); // Max 2 requests for testing when(request.getRemoteAddr()).thenReturn("127.0.0.1"); when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); } |
Now we can write tests for the filter’s behavior:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Test void shouldAllowRequestsUnderLimit() throws Exception { // When filter.doFilterInternal(request, response, filterChain); // Then verify(filterChain).doFilter(request, response); } @Test void shouldBlockRequestsOverLimit() throws Exception { // Given filter.doFilterInternal(request, response, filterChain); // First request filter.doFilterInternal(request, response, filterChain); // Second request // When filter.doFilterInternal(request, response, filterChain); // Third request // Then verify(response).setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS); verify(filterChain, times(2)).doFilter(request, response); } |
While this test validates the filter’s core logic, it has several limitations:
- Artificial environment: The test runs outside Spring’s context, so we don’t test how the filter is registered or ordered in the filter chain.
- Limited validation: We’re only verifying method calls on mocked objects, not the actual HTTP response behavior.
- Missing context: We can’t easily test how the filter interacts with actual controllers or other Spring components.
- Complex mock setup: For more sophisticated filters, setting up all the necessary mocks can be tedious and error-prone.
A Better Approach with @WebMvcTest
The @WebMvcTest
annotation creates a sliced Spring application context focused on the web layer.
This allows us to test our filter in a more realistic environment that includes Spring’s MVC infrastructure.
Before we test the filter, we define a simple test controller that we’ll use to verify the filter’s behavior. This controller has a single endpoint that returns a success message.
1 2 3 4 5 6 7 |
@RestController public class SampleController { @GetMapping("/api/test") public String test() { return "Test successful"; } } |
Let’s see how to test the filter using @WebMvcTest
:
1 2 3 4 5 |
@WebMvcTest(SampleController.class) class RateLimitingFilterWebMvcTest { @Autowired private MockMvc mockMvc; |
First, we set up a test class with the @WebMvcTest
annotation. This creates a Spring application context with just the web components.
The MockMvc
bean allows us to simulate HTTP requests to our application.ow we can write tests that use MockMvc
to send HTTP requests through our filter to the controller:
1 2 3 4 5 6 7 8 9 10 11 |
@Test void shouldAllowRequestsWithinRateLimit() throws Exception { // First request - should be allowed mockMvc.perform(get("/api/test")) .andExpect(status().isOk()) .andExpect(content().string("Test successful")); // Second request - should still be allowed mockMvc.perform(get("/api/test")) .andExpect(status().isOk()); } |
This test verifies that the first two requests are allowed through the filter, reaching the controller and returning a 200 OK status with the expected content.
Next, we test the rate-limiting behavior:
1 2 3 4 5 6 7 8 9 10 11 12 |
@Test void shouldBlockRequestsExceedingRateLimit() throws Exception { // Make two requests (our limit) mockMvc.perform(get("/api/test")).andExpect(status().isOk()); mockMvc.perform(get("/api/test")).andExpect(status().isOk()); // Third request - should be blocked mockMvc.perform(get("/api/test")) .andExpect(status().isTooManyRequests()) .andExpect(content().string("Rate limit exceeded")); } } |
This test verifies that after two successful requests, the third request is blocked by the filter, returning a 429 Too Many Requests status with the appropriate error message.
The @WebMvcTest
approach has several advantages:
- Realistic environment: The test runs within Spring’s context, testing how the filter integrates with Spring’s request processing pipeline.
- Complete validation: We can verify the entire HTTP response, including status code, headers, and content.
- Actual integration: We test the filter’s interaction with a real controller and Spring’s MVC infrastructure.
- Simplified setup: Spring handles the wiring and HTTP processing, so we don’t need complex mock configurations
Practical Testing Tips and Best Practices
When testing Spring MVC filters with @WebMvcTest
, consider these best practices:
- Isolate Test Scenarios: Configure each test class to focus on a specific filter or interaction pattern.
- Use
@Import
Selectively: Only import the filters and components necessary for your test to keep the context lightweight. - Testing Logging: For filters that log information, consider:
- Using a test appender with SLF4J
- Creating a testable wrapper around your filter that exposes what was logged
- Using a framework like Log4j’s
ListAppender
for capturing log events
- Test Filter Chain: Ensure your filter correctly calls
chain.doFilter()
and handles the filter chain properly. - Real HTTP Requests: For more comprehensive testing, consider using
@SpringBootTest
withTestRestTemplate
orWebTestClient
to make real HTTP requests through your filter chain. - Filter Registration Testing: If you’re registering filters programmatically using
FilterRegistrationBean
, include tests for the registration logic as well:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<RequestTimingFilter> requestTimingFilter() { FilterRegistrationBean<RequestTimingFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new RequestTimingFilter()); registrationBean.addUrlPatterns("/api/*"); registrationBean.setOrder(1); return registrationBean; } } @WebMvcTest @Import(FilterConfig.class) class FilterRegistrationTest { // Test filter registration } |
Summary
Testing Spring MVC filters effectively requires going beyond simple unit tests to validate their integration with Spring’s request processing pipeline.
By using Spring Boot’s @WebMvcTest
annotation, we can test filters within a realistic Spring context, ensuring they function correctly with controllers and other components.
Key takeaways from this article:
- Unit testing filters in isolation misses important integration aspects with Spring MVC
@WebMvcTest
provides a more comprehensive testing approach for filters- Use
@Import
to include your filters in the test Spring context - Test multiple filters together to validate their ordering and interactions
- Consider both happy paths and exception scenarios in your filter tests
With these techniques, we can develop robust, well-tested filters that reliably perform their cross-cutting concerns in our Spring Boot applications, from authentication to logging, rate limiting, and beyond. Remember that thorough filter testing contributes significantly to the overall stability and reliability of your application’s web layer.
Joyful testing,
Philip