1. Introduction
After switching to a more convenient style, a correct question quickly comes up: what happens when a request goes not only through the controller method, but also through the infrastructure around it. Filters and interceptors are a good stress test because they change not the business logic, but the observable HTTP effect.
And here it is easy to jump to the false conclusion that such scenarios already need some kind of “more serious” test type. They do not. We still stay in @WebMvcTest and still check what the HTTP client will see.
If you have just started writing web tests, it is very easy to think like this: “A controller is just an @GetMapping method, so I test the method and that is it.” But a real HTTP request does not teleport straight into the controller method. It goes through an entire infrastructure corridor, and in that corridor there are characters that sometimes make your life harder... and sometimes save it. Those are filters and interceptors.
In the ContentHub project, we want to learn how to test not “the whole Spring universe,” but a specific layer. And filters/interceptors are a tricky case: they are not the controller, but they are very close to the HTTP boundary. So it is important to agree on what we do test in @WebMvcTest and what we do not, otherwise the suite will turn into a 12-season series where every episode is about how the test “accidentally” started half the application.
For such stories, raw MockMvc is sometimes more convenient: for targeted header checks, perform(...).andExpect(...) reads more directly. That is not because MockMvcTester suddenly stopped working here. The test level does not change: we are still checking the same MVC slice.
2. Where Filter and HandlerInterceptor sit in the chain
To avoid testing by feel, it helps to see the map of the territory once: who runs when. Simplified, in classic Spring MVC (and in our @WebMvcTest) the request goes roughly like this.
flowchart TD
A[HTTP request] --> B[Filter chain]
B --> C[DispatcherServlet]
C --> D["HandlerInterceptor.preHandle"]
D --> E[Controller method]
E --> F["HandlerInterceptor.postHandle"]
F --> G["HandlerInterceptor.afterCompletion"]
G --> H[Filter chain completion]
H --> I[HTTP response]
Now — in human terms.
Filter is a Servlet API level component. It works “around everything” that happens inside DispatcherServlet. A filter can run before Spring MVC even starts choosing a controller, and it can intervene in the response after the controller has already finished. Filters are often used for things like adding headers, logging, setting encoding, CORS, tracing, and security (but security is a separate discussion).
HandlerInterceptor is a component already inside Spring MVC, closer to the controllers. It runs after Spring has found which handler will serve the request. So an interceptor can, for example, understand: “oh, this is a call to the PublicArticleController#getBySlug method,” and do something with that knowledge.
To avoid keeping everything in your head “like in a fog,” it is useful to pin down the difference in a small table:
| Characteristic | Filter | HandlerInterceptor |
|---|---|---|
| Level | Servlet API (before/after DispatcherServlet) | Spring MVC (inside handler processing) |
| Sees handler (controller method) | usually no | yes, receives handler |
| Typical effect | “general” HTTP things: headers, encoding, short-circuit | logic around specific handlers: pre/post/afterCompletion |
| How we test in an MVC slice | we check the observable HTTP result | we check the observable HTTP result |
The main idea to take away is this: both filters and interceptors are part of the web layer, but they “live” in different places along the request path. So sometimes you catch a bug that looks like “the controller did not return what it should,” while in fact the controller never ran at all — the filter returned a response earlier.
3. What to test in @WebMvcTest
It is very tempting to test everything. Sometimes the itch is real: “Since I already have a filter, let us verify that it called chain.doFilter, and that it logged a line, and that it set an attribute in the request, and that it said hello to the cat...”. Do not do that. In @WebMvcTest, we almost always check only observable HTTP behavior.
That sounds boring, but in practice it saves time and nerves. If a filter/interceptor changes what the HTTP client sees, that is part of the contract and it deserves a test. If a filter does an internal technical thing (for example, logs something), then checking logs in an MVC test usually creates more noise than confidence.
A useful mental model is this: in a web-slice test, we ask ourselves, “If the client hits the endpoint, what changes in the response because of this filter/interceptor?” And then we capture it through status/headers/body.
Below are typical effects that really make sense to test at the MVC boundary:
| What the filter/interceptor does | Why it matters | How we usually verify it |
|---|---|---|
| Adds a response header (for example, X-Request-Id) | Clients/logging/tracing depend on the header | header().exists(...) or header().string(...) |
| Blocks the request and returns an error (short-circuit) | The client should see a predictable failure | status().isBadRequest() etc. |
| Changes Content-Type / encoding | Affects the contract and readability of the response | content().contentType(...) |
| Does a redirect or changes the status | That is API behavior itself | status() and header("Location") |
But here is what you almost always do not need to do in @WebMvcTest:
We do not try to prove that a filter was “called exactly once,” do not check the order of filter calls unless it is critical and fixed by contract, and do not test internal methods or private filter logic. For internal logic, either a unit test will be enough if it is truly complex, or one verifiable HTTP effect will be enough.
4. Wiring filters and interceptors
@WebMvcTest is specifically designed to start not the whole context, but only the web layer. Because of that, many components that exist in the application as @Component do not get picked up automatically in the slice context. And that is good: tests are faster, there are fewer dependencies.
But this has a practical consequence: if you want to verify that your filter/interceptor really affects the HTTP result, you need to wire it explicitly into the test context.
The most direct (and usually the safest) way is @Import(...). It is good because you bring in exactly the class you need, not “let us import everything from the config package, there is definitely our interceptor in there.” That approach often ends with the MVC slice suddenly turning into a “full context without a database” — and then you wonder why the tests started taking a minute to run.
There is an important nuance: an interceptor is not just a class. It must be registered in MVC. In production, this is usually done by WebMvcConfigurer. In a test, you can either import the real configuration if it is small and does not drag in extra stuff, or write a minimal test configuration that registers the interceptor only for this test.
And that is the key principle of the lecture: “wire exactly what produces the observable effect, and only in the amount needed by the test.”
5. Example: a filter adds a public-zone header
Now let us take a very down-to-earth task. Suppose that in ContentHub we want public endpoints to be marked with the X-ContentHub-Zone: public header. The reasons can be anything: debugging, tracing, analytics, or just the desire to “know where the request landed.” What matters in this course is not the motivation, but how to test it in @WebMvcTest.
Let us start with a simple filter. For teaching code, it is very convenient to extend OncePerRequestFilter so we do not have to write extra Servlet scaffolding.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class PublicApiZoneHeaderFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// Add the header that should be visible to the HTTP client
response.addHeader("X-ContentHub-Zone", "public");
// Important: do not forget to pass control further down the filter chain
chain.doFilter(request, response);
}
}
The filter did exactly one thing: it added a header. We are not complicating the internals — we need to understand the testing approach.
Now the test. It is important that @WebMvcTest will not pick up this filter on its own, especially if it does not belong to the types that the slice automatically includes. So we wire it through @Import.
For a single header check, raw MockMvc reads honestly here: the low-level syntax does not hide the meaning. If you are more used to MockMvcTester, the idea does not change — we are still checking only the response that the client will see.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PublicArticleController.class) // Start the MVC slice only for the controller
@Import(PublicApiZoneHeaderFilter.class) // Explicitly wire the filter into the test context
class PublicArticleControllerWebMvcTest {
@Autowired MockMvc mockMvc;
// Mock the controller dependency so the controller can run and return 200
@MockitoBean ArticleQueryService articleQueryService;
@Test
void shouldAddPublicZoneHeader() throws Exception {
// Stabilize the controller behavior: let it return an empty page
given(articleQueryService.findPublished(any()))
.willReturn(PageResponse.empty());
// Make an HTTP request at the MVC boundary and verify the observable contract
mockMvc.perform(get("/api/public/articles")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(header().string("X-ContentHub-Zone", "public"));
}
}
Notice the “grown-up” neatness of this test. We do not check that the filter was called. We do not check the call order. We do not check how the filter is built internally. We capture the observable contract: when the public endpoint is requested, the client receives the header.
And it is also worth noting: the ArticleQueryService mock is needed here not because “we are testing the service,” but because we need the controller to return 200 OK normally, otherwise we would be testing a controller failure rather than the filter effect. Yes, sometimes an infrastructure test needs minimal controller stabilization — that is normal.
6. Example: an interceptor adds X-Request-Id
With interceptors, confusion happens often: it feels like “well, I created RequestIdInterceptor as a bean, so it should work.” But it will not work until it is registered in MVC. In production code, configuration usually does that; in a test, either import a small configuration or create a test configuration specifically for that slice.
Let us make an interceptor that adds the X-Request-Id header. The value will be random (via UUID), and in the test we will check only that the header exists. That way we do not make the test fragile.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.UUID;
public class RequestIdInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// Generate a request id for every request and make it visible to the client
response.setHeader("X-Request-Id", UUID.randomUUID().toString());
// true means "continue processing and go to the controller"
return true;
}
}
Now the minimal configuration that wires this interceptor in. We will make it @TestConfiguration so that it exists only for tests (and so we do not accidentally turn it into a production configuration).
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@TestConfiguration
public class RequestIdInterceptorTestConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Register the interceptor in Spring MVC, otherwise it will not be called
registry.addInterceptor(new RequestIdInterceptor());
}
}
And now the @WebMvcTest itself, which imports this configuration.
To keep the example from arguing with the controller itself, we use the same minimal service stub as in the previous fragment. Here we are not interested in the list of articles, but in the very fact that X-Request-Id appears.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PublicArticleController.class) // MVC slice for the controller
@Import(RequestIdInterceptorTestConfig.class) // Wire interceptor registration
class PublicArticleControllerRequestIdWebMvcTest {
@Autowired MockMvc mockMvc;
@MockitoBean ArticleQueryService articleQueryService;
@Test
void shouldReturnRequestIdHeader() throws Exception {
// The controller should return 200 normally so the only new effect is the header from the interceptor
given(articleQueryService.findPublished(any()))
.willReturn(PageResponse.empty());
// Check only the observable behavior: a header must appear in the response
mockMvc.perform(get("/api/public/articles")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"));
}
}
Here we again do something important: we check only what should be visible to the client. We did not go on to verify the UUID format, even though we could have, because within the course the goal is to show the principle: an interceptor affects the HTTP contract, so its effect can and should be captured in an MVC slice test.
If you want a slightly stricter test, you can verify that the header is not empty, but usually exists already gives reasonable confidence and does not make the test fragile.
How not to bloat infrastructure tests
When you first start wiring filters and interceptors into @WebMvcTest, the following situation is almost inevitable. You added @Import, the test suddenly stopped starting, and Spring begins complaining about some bean you “kind of do not need.” This is a normal “teenage phase” of web-slice tests.
Here it is important to remember: @WebMvcTest is not an attempt to simulate a production application. It is an attempt to quickly and cheaply prove a specific piece of the web contract. So the right reaction to the problem “the filter pulls in extra dependencies” is usually not “let us import the whole application,” but rather “let us simplify the filter / give it a small dependency / mock the dependency with @MockitoBean / enable the filter only in the tests where it is truly important.”
Another typical trap is testing the filter order or whether the interceptor runs “before the controller” or “after.” In practice, order matters only in rare cases, and then it must be part of an explicitly fixed behavior. But more often, order is just “how it happens to be configured right now,” and such a test will break on any refactoring, even if the external contract has not changed.
And finally, do not mix failure causes. If you simultaneously check “the controller returned the right body” and “the filter added the right header” in one huge test, you make diagnosis harder. It is better to have a test “the controller returns a JSON structure” and a test “the infrastructure adds a header.” Then, when something fails, you understand faster who is to blame — the controller or the corridor around it.
7. Typical mistakes in @WebMvcTest
Mistake #1: trying to test the internal implementation of the filter/interceptor instead of the observable HTTP effect.
It is very tempting to write a test that checks that chain.doFilter(...) was called inside the filter, or that the interceptor put an attribute into the request. The problem is that this turns the test into a check of “how the code is written,” not “what the client sees.” For @WebMvcTest, it is almost always stronger and more stable to test status/headers/body, because that is the web contract.
Mistake #2: importing too broad a configuration for the sake of one filter.
A common story: “our filter is registered in WebConfig, let us import WebConfig.” And WebConfig pulls in another 20 beans: formatters, converters, properties, and suddenly you are close to a full context. As a result, tests get slower, there are more contexts, and you lose the main value of the slice approach. It is much more useful to import just the filter itself or a small test configuration that does exactly one registry.addInterceptor(...).
Mistake #3: forgetting that an interceptor must be registered in MVC, not just created as a class.
An interceptor without registration in WebMvcConfigurer is like a guard without a pass: it exists, but it is not allowed onto the shift. If in the test you imported only the interceptor class and not the configuration that adds it to the InterceptorRegistry, it will not be called, and you will stare at the test for a long time wondering why the header did not appear.
Mistake #4: turning an infrastructure test into a “mega-scenario” with lots of assertions.
Infrastructure components usually produce a small but important effect: one header, one status, one blocking rule. If you try to verify JSON body, headers, and something else all in one test, you make the test long and hard to diagnose. It is much easier to keep the scenario short: request → status → specific header.
Mistake #5: checking the exact value when checking the fact is enough.
For headers like X-Request-Id, the value is often generated dynamically. If you try to compare it “exactly,” you will make the test unstable yourself. In such cases, it is better to check that the header exists and looks reasonable (for example, is not empty) than to try to force dynamic data into strict expectations.
GO TO FULL VERSION