1. Подключаем MockMvc к реальной SecurityFilterChain
Когда мы начинаем писать тесты, мозг, особенно мозг начинающего разработчика, хочет одного: чтобы тесты были зелёные, сборка проходила, и жизнь казалась управляемой. Проблема в том, что security-тесты могут стать “зелёными” по неправильной причине: вы случайно проверяете контроллер мимо security, и тесты подтверждают не безопасность, а вашу способность обойти турникет через служебный вход.
Представьте себе реальный сценарий. В проде запрос идёт так: сначала фильтры, потом Spring Security принимает решение “пускать / не пускать”, и только потом дело доходит до DispatcherServlet и контроллера. Если в тесте вы проверяете контроллер напрямую или собираете MockMvc в режиме “только контроллер”, вы как будто тестируете музей, убрав металлоискатель. Музей станет очень “дружелюбным”… но это сомнительный комплимент.
В результате получаются особенно коварные баги: вы написали тест “public endpoint отдаёт 200”, и он зелёный даже если security вообще не подключена, потому что public и так отдаёт 200. А потом вы добавляете правило в SecurityFilterChain, случайно закрываете public-зону — и тест внезапно начинает падать. Вы не понимаете, “что сломалось”: endpoint? security? тесты? На самом деле сломалось базовое предположение: ваш тестовый стенд должен быть максимально похож на реальный web-стенд, иначе тесты превращаются в художественную литературу.
2. MockMvc в lifecycle запроса и фильтры
MockMvc часто воспринимают как “ну это типа HTTP-запрос, только без сервера”. И это почти правда. Но ключевое слово здесь — почти. MockMvc действительно позволяет сделать запросы GET/POST/PATCH и получить ответ, не поднимая Tomcat/Jetty/Netty. Однако он пытается честно воспроизвести серверный путь обработки запроса внутри Spring MVC. А в серверном пути до контроллера всегда стоят фильтры.
Давайте зафиксируем простую схему. В реальном приложении запрос идёт от клиента, попадает в цепочку servlet filters, затем, если его не “завернули”, доходит до DispatcherServlet, который уже маршрутизирует всё в ваш контроллер. Spring Security живёт именно в фильтрах: это не “аннотация на контроллере”, а полноценная фильтровая инфраструктура.
Если MockMvc собран правильно, тестовый запрос пойдёт по примерно такому пути:
flowchart TD
T["JUnit Jupiter test"] --> M["MockMvc.perform(...)"]
M --> F["Servlet filters
включая springSecurityFilterChain"]
F --> D["DispatcherServlet"]
D --> C["Controller"]
C --> R["MockMvcResult / response"]
Если MockMvc собран неправильно, частый случай — standaloneSetup, — то блок Servlet filters исчезает. И вы тестируете уже не “приложение”, а “кусок приложения”.
Отсюда практическое правило на сегодня: security-тесты должны прогонять запрос через реальную SecurityFilterChain, иначе вы не тестируете безопасность, вы тестируете надежду.
3. Настройка окружения и сборка MockMvc
3.1. Подготовка проекта: зависимости для security-тестов
Перед тем как мы начнём собирать MockMvc, нужно убедиться, что у нас вообще есть инструменты для security-aware тестов. В Spring мире это выглядит как отдельная мысль: Spring Test даёт нам MockMvc, а Spring Security отдельно даёт модуль, который умеет корректно встраиваться в тестовую инфраструктуру. И да, если вы забудете зависимость, всё будет как в жизни: “вроде делаю правильно, но почему springSecurity() не находится?”
В build.gradle.kts или build.gradle обычно нужен стандартный тестовый стек Spring Boot и отдельная зависимость spring-security-test. В рамках Boot dependency management версии указывать не нужно — Boot сам подберёт согласованные.
Пример:
dependencies {
// Базовый набор для тестов Spring Boot (JUnit, AssertJ, MockMvc и т.д.)
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Интеграция Spring Security с тестовой инфраструктурой (springSecurity(), @WithMockUser, user() и т.п.)
testImplementation("org.springframework.security:spring-security-test")
}
Если вы видите в IDE, что SecurityMockMvcConfigurers.springSecurity() не резолвится, или у вас нет user() / @WithMockUser, то в 9 случаях из 10 проблема именно здесь. И да, это тот самый момент, когда “одна строчка в Gradle” спасает вам вечер и нервную систему.
3.2. Базовый путь для курса: @SpringBootTest + @AutoConfigureMockMvc
Самый спокойный старт для Spring Boot проекта — это не пытаться изобрести велосипед, а взять базовый шаблон теста, который поднимает контекст приложения и выдаёт вам готовый MockMvc. Это особенно важно в security-курсе: нам нужна реальная SecurityFilterChain, а она в нормальном приложении — часть контекста, с бинами, настройками, handler’ами ошибок и всем тем, что вы уже собрали в проекте.
В нашем курсе мы обычно начинаем с такого каркаса теста:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest // Поднимаем полный Spring Boot контекст (включая реальную SecurityFilterChain)
@AutoConfigureMockMvc // Регистрируем MockMvc как bean и подключаем фильтры
class SecurityAccessTests {
@Autowired
MockMvc mockMvc; // Этим MockMvc мы делаем запросы так, как если бы они шли через реальный web-стенд
}
Несколько важных и практичных наблюдений. @SpringBootTest поднимает Spring Boot контекст, то есть вы получаете ту же SecurityFilterChain, что и в runtime. @AutoConfigureMockMvc добавляет в контекст bean MockMvc, уже сконфигурированный для работы со Spring MVC.
Если вам хочется сделать тесты более предсказуемыми по конфигурации, вы часто добавляете @ActiveProfiles("test"), чтобы брать отдельный application-test.yml. Но сегодня мы держим фокус на главном: запрос в тесте должен пройти через те же security-фильтры, что и в приложении.
3.3. Ручная сборка MockMvc и springSecurity()
Автонастройка удобна, но иногда жизнь подкидывает ситуации, где MockMvc нужно собрать вручную: например, вы хотите чуть больше контроля, или у вас не Spring Boot тест. И вот здесь появляется классическая ловушка: вы собрали MockMvc, он выполняет запросы, тесты бегают… но security в этих запросах не участвует.
Чтобы ручная сборка была “по-взрослому”, нужно собирать MockMvc не через standaloneSetup, а через WebApplicationContext. И обязательно применить springSecurity() — это специальный тестовый конфигуратор, который правильно связывает Spring Security и MockMvc.
Минимальный пример:
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
class SecurityAccessTests {
@Autowired
WebApplicationContext context; // Реальный web-контекст приложения (с зарегистрированными фильтрами)
MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = webAppContextSetup(context)
// Важно: подключаем интеграцию Spring Security Test,
// иначе запросы могут пройти "мимо" SecurityFilterChain
.apply(springSecurity())
.build();
}
}
Здесь важна не магия, а инженерная честность. webAppContextSetup(context) означает: “собери мне тестовый web-стенд на основе реального Spring контекста, с зарегистрированными фильтрами”. .apply(springSecurity()) означает: “вкрути в этот стенд интеграцию Spring Security Test, чтобы security-контекст и фильтры работали как положено”.
А вот standaloneSetup(...) в security-тестах — это почти всегда плохая идея, потому что он удобен для тестирования контроллера как “чистого класса”, но по определению не обязан знать ничего про вашу фильтровую инфраструктуру. То есть он тестирует “контроллер”, а не “приложение на границе”.
4. Smoke tests: проверяем, что security подключена
Пока это не regression pack. Здесь задача скромнее: быстро проверить, что тестовый стенд вообще проводит запрос через security и честно показывает границу между public и protected endpoint’ами.
Перед тем как писать красивую матрицу тестов для USER/EDITOR/ADMIN, полезно сделать две короткие проверки, которые играют роль “лампочки питания”. Это не “полноценные security-тесты”, это диагностика: “я вообще подключил фильтры или бегаю по пустому коридору?”
Первый тест обычно берётся из public-зоны. Если public-чтение сломалось — вы сразу это увидите.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void publicArticlesAreAccessibleForAnonymous() throws Exception {
// Анонимный запрос в публичную зону должен проходить
mockMvc.perform(get("/api/public/articles"))
.andExpect(status().isOk());
}
Второй тест берётся из личной зоны, которая обязана требовать аутентификации. В нашем проекте REST-семантика настроена так, что anonymous должен получить 401, а не редирект на HTML-страницу логина.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void meEndpointRequiresAuthentication() throws Exception {
// Без аутентификации private endpoint должен отвечать 401 Unauthorized
mockMvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
Эти проверки ещё не описывают всю access matrix. Они отвечают на один базовый вопрос: security реально участвует в тесте или вы всё ещё бегаете мимо фильтров.
Если второй тест внезапно возвращает 200, это очень сильный сигнал: либо у вас фильтры не подключены, либо /api/me почему-то стал публичным, что для нашей access matrix почти наверняка ошибка. И именно поэтому эти два теста полезно написать сразу после настройки окружения, ещё до всего “интересного”.
5. Автонастройка и ручная сборка: как выбрать
Когда начинающий разработчик видит два пути, он часто делает третий: берёт оба и смешивает “на всякий случай”. В тестах это особенно опасно: вы можете получить ситуацию, когда часть тестов ходит через один MockMvc, часть — через другой, где security подключена иначе, а потом вы тратите время на “почему одинаковые запросы ведут себя по-разному”.
Чтобы мозг не кипел, удобно сравнить подходы в одной таблице:
| Подход | Что даёт | Что стоит помнить |
|---|---|---|
| @SpringBootTest + @AutoConfigureMockMvc | Быстрый старт, MockMvc как bean, высокая похожесть на runtime | Контекст полный, тесты могут быть чуть тяжелее, но для regression pack это нормально |
| webAppContextSetup(context) .apply(springSecurity()) | Полный контроль над сборкой MockMvc, можно добавлять кастомные настройки | Легко забыть springSecurity() и получить “тесты без security” |
Здесь логика простая. Если у вас нет причины собирать вручную — не надо. Пишите базовые тесты на автонастройке, и пусть ваши силы уйдут на смысл, то есть на проверки доступа, а не на инженерную археологию “почему тут фильтры не сработали”.
И ещё один момент, который стоит сказать вслух. Иногда разработчики, из лучших побуждений, отключают фильтры, чтобы “ускорить тесты”. В @AutoConfigureMockMvc это делается одним параметром… и это кнопка “сделать security-тесты бессмысленными”. Если вы пишете именно security-tests, фильтры отключать нельзя, иначе вы буквально тестируете приложение в параллельной вселенной.
6. Типичные ошибки при подключении MockMvc к SecurityFilterChain
Ошибки в этой теме неприятны тем, что часто выглядят как “ну тесты же зелёные”. Да, зелёные. Просто проверяют не то. Поэтому ниже — самые частые грабли, которые лучше обойти, пока они ещё лежат на дорожке и не успели ударить вас по лбу.
Ошибка №1: собрать MockMvc через standaloneSetup и ожидать, что security будет работать.
standaloneSetup хорош, когда вы тестируете контроллер как изолированный объект и хотите проверить маппинг, параметры и сериализацию. Но он не обещает вам реальную фильтровую инфраструктуру. В security-тестах это приводит к эффекту “контроллер отвечает, значит доступ разрешён”, хотя в реальности доступ решается до контроллера.
Ошибка №2: забыть про зависимость spring-security-test.
Это выглядит банально, но встречается постоянно: вы пытаетесь сделать .apply(springSecurity()), а метода как будто не существует. Или вы хотите использовать будущие инструменты вроде user() и @WithMockUser, а IDE смотрит на вас с выражением “а что это вообще?”. Обычно проблема решается одной строчкой в dependencies, но пока вы её не добавили, вы реально пытаетесь готовить борщ без кастрюли.
Ошибка №3: вручную собрать MockMvc, но не применить .apply(springSecurity()).
Визуально тесты могут продолжить бегать, особенно на public-endpoint’ах. Именно поэтому ошибка коварная: вы пишете тест “public OK” — зелёный, радуетесь. Потом пишете тест “/api/me даёт 401” — и получаете 200. Начинаете подозревать контроллер, security config, фазу луны… хотя проблема в том, что security вообще не участвовала в обработке запроса.
Ошибка №4: отключить фильтры в @AutoConfigureMockMvc и не заметить этого.
Отключение фильтров — популярный “ускоритель”, но в security-тестах это как “ускоритель автомобиля” в виде снятых тормозов: да, быстрее, но радость сомнительная. Если фильтры отключены, запросы не проходят через SecurityFilterChain, и вы не проверяете access rules. Вы проверяете только то, что контроллер умеет возвращать ответы.
Ошибка №5: ожидать не тот статус из-за несогласованной security-семантики.
Если ваш API уже приведён к REST-friendly поведению, то anonymous на private endpoint должен получать 401, а аутентифицированный пользователь без прав — 403. Если вы внезапно видите 302 редирект на /login, это намекает, что вы тестируете browser-семантику или используете конфигурацию, которая отдаёт HTML-redirect вместо JSON-ошибки. Важно не “подогнать тест под странный статус”, а понять, какая семантика должна быть в вашем проекте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ