1. spring-security-test для security-тестів
Матриця доступу сама себе не перевірить: у тесті потрібно вміти явно задавати, хто саме робить запит.
Коли ви вперше вмикаєте Spring Security у Spring Boot-застосунку, відбувається магія, яка спочатку тішить, а потім трохи дратує. Тішить — тому що «ніби все захищено». Дратує — тому що раптово майже будь-який запит у тестах перетворюється на 401 або 403, і ви сидите й думаєте: «Я тестую контролер… чи раптом усю цивілізацію безпеки?». У цей момент багато хто починає або вимикати фільтри «щоб тести проходили», або намагатися вручну заповнити SecurityContextHolder. Саме тут і зʼявляється spring-security-test: бібліотека, яка дає вам офіційний і зручний спосіб моделювати користувача в тестах — так, щоб тест залишався читабельним.
У нашому курсі це особливо важливо, тому що ми вже вміємо тестувати MVC-шар та інтеграційні сценарії, а тепер просто додаємо ще одну вісь: «хто саме виконує запит». spring-security-test допомагає зробити цю вісь явною в коді тесту: або анотацією @WithMockUser, або налаштуванням конкретного запиту через request post-processors (user(...), anonymous(...), httpBasic(...)).
Якщо дивитися прагматично, ця залежність заощаджує вам години часу: замість того, щоб гадати, чому запит упав і як «підсунути користувача», ви пишете прямо в тесті зрозумілу фразу: «виконати запит як admin». І далі тест знову стає тим, чим він має бути: документацією поведінки.
Тут є три передумови, щоб далі не ловити примарну магію. По-перше, spring-security-test — це окрема test-dependency org.springframework.security:spring-security-test, а не безплатний бонус, який сам собою раптово зʼявився в проєкті. По-друге, усі приклади нижче спираються на вже наявну security-конфігурацію ContentHub: ролі EDITOR/ADMIN, публічну зону та робочий спосіб автентифікації в застосунку. І по-третє, @WithMockUser та user() живуть у server-side Spring Test path: вони доречні в @WebMvcTest, у @SpringBootTest + @AutoConfigureMockMvc і в method-security тестах на Spring-managed bean. Якщо ви піднімаєте живий сервер і звертаєтеся до нього клієнтом, тоді вже потрібні реальні credentials та звичайний заголовок Authorization.
2. Де живе користувач
Якщо ви новачок, є одна річ, яку важливо прийняти: потім жити буде легше. У звичайному запиті користувач не потрапляє в контролер напряму. Спочатку запит проходить через security filter chain, і лише якщо ланцюжок вирішує, що все гаразд, керування доходить до MVC і вашого @RestController. Тобто з погляду Spring Security головним обʼєктом є не ваш DTO і навіть не ваш контролер, а Authentication у SecurityContext.
Усередині Spring Security це виглядає приблизно так: фільтри намагаються зʼясувати, хто ви, формують обʼєкт Authentication (де є username і список прав), кладуть його в SecurityContext, а далі механізм авторизації вирішує, чи можна вам у цей endpoint або метод. Якщо ви взагалі не автентифіковані — контекст порожній або містить “anonymous authentication” (залежно від конфігурації). Якщо ви ввійшли — у контексті є Authentication з вашими ролями/authorities.
У тестах ми хочемо керувати цим без зайвих маніпуляцій. І тут є два принципово різні підходи:
1) Ми можемо симулювати вже автентифікованого користувача, не перевіряючи, як саме він увійшов. Це шлях @WithMockUser і user(). Він добрий, коли нас цікавить авторизація (доступ за ролями/правами), а не механізм входу.
2) Ми можемо пройти через реальний механізм входу, наприклад через HTTP Basic, тобто додати до запиту заголовок Authorization: Basic ... і дати фільтрам самостійно все обробити. Це шлях httpBasic(). Він корисний, коли ви хочете переконатися, що ваша security-конфігурація справді працює так, як задумано, а не лише «у тестовій уяві».
Ця модель — головний орієнтир для вибору інструмента в тесті. Якщо ви її тримаєте в голові, далі все стає доволі логічним, а не «магією анотацій».
3. @WithMockUser: швидкий мок-актор
@WithMockUser — це, по суті, офіційний «чит-код» для тесту. Але не в поганому сенсі, а в інженерному. Він дає змогу виконати тест так, ніби в SecurityContext уже лежить автентифікований користувач із потрібним username та ролями. При цьому жодні реальні паролі не перевіряються, жодна база користувачів не потрібна, і ваш тест не перетворюється на курс «як влаштований вхід».
На практиці @WithMockUser чудово підходить для MVC-тестів (і slice, і full-context), коли ви хочете перевірити саме правило доступу: хто може потрапити в endpoint. Особливо добре він лягає на сценарії, де весь тест виконується від імені одного актора: «цей тест — як editor», «цей — як admin». У такому режимі код читається майже як речення українською.
Мініприклад: запит до editor endpoint від імені editor-користувача.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
// Кладемо в SecurityContext користувача з потрібними правами, оминаючи реальний вхід
@WithMockUser(username = "alice", roles = "EDITOR")
void createDraft_asEditor_isOk() throws Exception {
// Важливо: сам запит звичайний, а "магія" відбувається в security-тестовій інфраструктурі
mockMvc.perform(post("/api/editor/articles"))
// Перевіряємо, що авторизація пропускає такого користувача
.andExpect(status().is2xxSuccessful());
}
Зверніть увагу на два моменти. По-перше, username тут не декоративний. Якщо у вас десь у правилах доступу є логіка «свій/чужий ресурс», то без осмисленого імені ви просто не зможете нормально перевіряти сценарії. По-друге, EDITOR виглядає майже як бізнес-термін нашого домену, і це дуже добре: тест не має бути написаний мовою «ROLE_технічні_букви_внутрішніх деталей».
4. roles і authorities: про ROLE_
Тема ролей і authorities здається дрібницею, доки ви не зловите тест, який «нібито правильний», але повертає неочікуваний статус. Тож краще розібратися зараз, щоб потім не влаштовувати розслідування в стилі «хто вкрав мій доступ».
У Spring Security історично склалося так: role — це приватний випадок authority, просто з префіксом ROLE_. Тобто роль ADMIN у реальності зберігається як authority ROLE_ADMIN. Саме тому @WithMockUser(roles = "ADMIN") автоматично створить authority ROLE_ADMIN. І саме тому писати roles = "ROLE_ADMIN" майже завжди помилка: ви отримаєте ROLE_ROLE_ADMIN, а це вже звучить як начальник начальників, але, на жаль, не працює.
Якщо ж ви хочете задати права точніше (наприклад, не роль, а конкретне право), тоді ви використовуєте authorities. У межах ContentHub нам базово вистачає ролей EDITOR і ADMIN, але корисно розуміти різницю хоча б на рівні «я знаю, чому воно так».
Мініприклад із authorities, щоб відчути різницю:
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
@Test
// Тут задаємо окреме право напряму, без ROLE_-префікса і без ролей
@WithMockUser(username = "admin", authorities = "ARTICLE_APPROVE")
void approve_withAuthority_isOk() throws Exception {
// Такий тест перевіряє саме правило доступу — авторизацію за authority
mockMvc.perform(post("/api/admin/articles/42/approve"))
.andExpect(status().is2xxSuccessful());
}
Такий варіант має сенс, якщо ваша система проєктується не лише на ролях, а на «гранулярних» правах. Але якщо проєкт тримається на ролях, краще не змішувати підходи просто заради різноманітності: у тестах і так вистачає місць, де можна випадково створити хаос.
5. Post-processors для MockMvc
Анотація @WithMockUser зручна, але вона працює на рівні тестового методу (або класу). А що, якщо ви пишете один тест, який робить два запити, і другий запит має бути від іншого користувача? Або ви будуєте невелику «матрицю» перевірок і хочете, щоб актор був видимий просто в рядку із запитом? Тоді набагато зручніше використовувати request post-processors.
Request post-processor — це штука, яка «донастроює» запит перед виконанням. Для MockMvc їх надає Spring Security Test у вигляді набору методів, які зазвичай імпортуються статично. Найважливіші для нас сьогодні: anonymous(), user(...), httpBasic(...).
Приклад: явно виконати запит як anonymous — це корисно навіть тоді, коли за замовчуванням тести можуть виконуватися без користувача, бо ви робите свій намір явним.
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void publicEndpoint_asAnonymous_isOk() throws Exception {
// Явно задаємо анонімного користувача, тобто без автентифікації
mockMvc.perform(get("/api/public/articles").with(anonymous()))
// Очікуємо, що публічна зона доступна без входу
.andExpect(status().is2xxSuccessful());
}
Приклад: виконати запит як конкретний editor-користувач, але не через анотацію, а прямо в запиті.
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void editorReadsOwnEndpoint_asUserPostProcessor() throws Exception {
// Користувача задаємо на рівні конкретного запиту — зручно, коли в тесті кілька акторів
mockMvc.perform(get("/api/editor/articles").with(user("alice").roles("EDITOR")))
// Тут і далі перевіряємо авторизацію, а не реальний механізм входу
.andExpect(status().is2xxSuccessful());
}
І третій важливий інструмент — httpBasic(). Він відрізняється за змістом: user() і @WithMockUser «підкладають» користувача напряму в контекст тесту, а httpBasic() робить запит більш схожим на реальний, тому що додає HTTP Basic credentials у запит і дає фільтрам відпрацювати вхід.
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void adminApproves_asHttpBasic_isOk() throws Exception {
// Передаємо реальні credentials у запит (Authorization: Basic ...)
mockMvc.perform(post("/api/admin/articles/42/approve").with(httpBasic("admin", "password")))
// Якщо basic auth, користувач і пароль налаштовані, запит має пройти
.andExpect(status().is2xxSuccessful());
}
Якщо у вашому застосунку справді налаштовані in-memory users для курсу (а в ContentHub вони якраз мають бути), такий тест уже починає перевіряти не лише «чи пускають роль ADMIN», а й те, що admin взагалі існує як користувач, що пароль читається, і що HTTP Basic увімкнено. Це інший рівень впевненості, і інколи він дуже корисний.
6. Вибір способу задати користувача
Коли у вас є три інструменти, новачок зазвичай обирає їх за принципом «який коротший». Але краще обирати за змістом, інакше тест буде зеленим, а впевненості не додасть. Це найгірша угода в тестуванні: багато коду, мало користі.
Нижче — невелика таблиця-навігатор. Вона не замінює мислення, але допомагає не заблукати.
| Спосіб задати користувача | Що ви насправді перевіряєте | Коли особливо зручно | Головний ризик/обмеження |
|---|---|---|---|
| @WithMockUser | авторизація (правила доступу), без реального входу | коли весь тест — «під одним актором» | легко забути, що пароль і вхід узагалі не перевіряються |
| .with(user("...")) | авторизація на рівні конкретного запиту | коли в одному тесті різні актори або коли хочете «бачити актора» поруч із URI | усе ще не перевіряє реальний механізм автентифікації |
| .with(httpBasic("...","...")) | більш реальний шлях входу через HTTP Basic | коли хочете бути ближче до production-like конфігурації | тест стає трохи «дорожчим» і залежить від налаштування користувачів у застосунку |
Загальна практична порада для курсу ContentHub: якщо ви робите чисту матрицю «актор → endpoint → результат», вам часто достатньо @WithMockUser або user(). А ось якщо ви хочете переконатися, що ваш інтеграційний сценарій справді проходить із реальними credentials (особливо у full-context тестах), тоді httpBasic() — чудовий варіант.
7. Приклади для ContentHub
Оскільки ми вже вміємо писати і @WebMvcTest, і @SpringBootTest + @AutoConfigureMockMvc, важливо зрозуміти, що spring-security-test не «привʼязаний» до одного виду тестів. Він працює всюди, де Spring Test узагалі підіймає контекст і підключає інфраструктуру безпеки.
У MVC slice (@WebMvcTest) ви часто мокаєте сервіси контролера і фокусуєтеся на межі HTTP. У такому тесті @WithMockUser зазвичай ідеально підходить: ви явно задаєте роль, робите запит і перевіряєте, що контролер доступний або недоступний. При цьому ви не ускладнюєте тест перевіркою реального входу — тому що мета slice-тесту інша: швидко й локально перевірити вебмежу.
У full-context тесті (@SpringBootTest + @AutoConfigureMockMvc) ви ближче до «реального застосунку», і у вас є вибір. Якщо ви хочете просто перевірити доступність endpoint-а для ролі, використовуйте @WithMockUser. Якщо ж ви хочете переконатися, що ваш security setup справді «як у житті» — хай і в навчальному проєкті, — використовуйте httpBasic(). Це особливо корисно, коли security-конфігурація змінюється і ви боїтеся регресій на кшталт «ми випадково вимкнули basic auth» або «перейменували користувача в конфігу».
Мініфрагмент full-context тесту з httpBasic(), у стилі «перевіряю доступ через реальні credentials»:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void adminListing_asHttpBasic_isOk() throws Exception {
// У full-context тесті це ближче до реальної поведінки: фільтри самі обробляють автентифікацію
mockMvc.perform(get("/api/admin/articles").with(httpBasic("admin", "password")))
.andExpect(status().is2xxSuccessful());
}
Тут немає жодної бізнес-логіки публікації. І це добре. Це не regression flow, це точкова перевірка: «щонайменше адмін може зайти в адмінську зону».
8. Типові помилки під час задання користувача в security-тестах
Помилка №1: очікувати, що @WithMockUser спрацює в тесті без Spring-контексту.
@WithMockUser — це не магія JUnit сама по собі, це інтеграція зі Spring Test. Якщо ви пишете звичайний unit-тест без @SpringBootTest, без @WebMvcTest і взагалі без участі Spring TestContext, анотація не «підчепиться», а ви будете дивитися на 401/403 як на загадку всесвіту.
Помилка №2: писати roles = "ROLE_ADMIN" і потім дивуватися, що доступ не зʼявився.
Для roles префікс ROLE_ додається автоматично. Якщо ви явно прописуєте ROLE_ADMIN, ви часто отримуєте неправильний authority (ROLE_ROLE_ADMIN). Тест виглядає правдоподібно, і від цього він особливо підступний: здається, що «все як у доках», а по факту — ні.
Помилка №3: використовувати @WithMockUser, коли ви насправді хотіли перевірити реальний спосіб входу.
Іноді команда думає, що перевірила «basic auth працює», але написала тест на @WithMockUser. Такий тест перевірив лише правила авторизації, а механізм автентифікації обійшов стороною. Якщо вам важливо переконатися, що credentials справді обробляються, використовуйте httpBasic() (або в тестах із живим сервером — реальний заголовок Authorization).
Помилка №4: ховати актора надто глибоко в helper і втратити читабельність.
Зручно зробити метод asEditor() і забути, хто там усередині. Але якщо security-логіка завʼязана на username (а доступ, привʼязаний до власника, майже завжди так і влаштований), то «безіменні» helpers перетворюють тест на ребус. Користувач у security-тесті має бути видимим, інакше тест перестає бути документацією.
Помилка №5: отримати 403 і автоматично думати «значить, роль неправильна».
403 — це просто «заборонено», але причин може бути кілька: недостатня роль, відсутність CSRF-токена (якщо він увімкнений), відсутність потрібної authority або навіть те, що запит потрапив в інший security matcher. Перш ніж виправляти тест, корисно переконатися, що ви задаєте користувача саме тим способом, який відповідає вашій конфігурації (мок-актор чи реальний basic auth).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ