1. Ендпойнт завантаження: не просто файл
Коли ми тестуємо JSON-ендпойнт, мозок швидко звикає до простого шаблону: статус, Content-Type: application/json, кілька перевірок JsonPath — і життя прекрасне. Із завантаженням файлів усе трохи підступніше: відповідь бінарна, сенс «сховано» в заголовках, а помилка все одно має приходити у вашому стабільному ApiProblem, а не у вигляді випадкової HTML-сторінки. Саме тут у новачків зазвичай і з’являється класична пастка: начебто все працює в браузері, а насправді контракт «плаває».
Якщо спростити, ендпойнт завантаження в ContentHub — це момент, коли HTTP починає поводитися як кур’єр: він не розповідає історію, він несе коробку. І на коробці є наклейки (headers), без яких клієнт або відкриє не те, або назве файл «download», або взагалі вирішить, що це текст, і покаже кракозябри. Тому тест контролера тут має бути трохи більш приземленим: менше філософії про домен, більше перевірки конкретних байтів і заголовків.
У нашому проєкті ContentHub це стосується ендпойнта: GET /api/editor/articles/{id}/attachments/{attachmentId}
І ми тестуватимемо саме HTTP-представлення: як за URI та path variables отримується правильна відповідь зі статусом, Content-Disposition, Content-Type і тілом.
2. Контракт завантаження: headers і bytes
Щоб тест не перетворився на випадковий набір очікувань, корисно спочатку проговорити, що взагалі становить контракт завантаження. Тут контракт живе не лише в URI та статусі, а й у тому, як клієнтові пояснюють «що це за дані» і «як їх назвати». І так, заголовки в цьому місці важливіші, ніж ваша віра в добро і справедливість.
Нижче — компактна «карта» того, що найчастіше має сенс фіксувати в MVC-тесті для ендпойнта завантаження:
| Частина відповіді | Що це означає для клієнта | Як зазвичай перевіряємо в MockMvc | Чому це важливо |
|---|---|---|---|
| 200 OK | завантаження дозволено й ресурс знайдено | status()(.isOk() | без цього ми навіть не в тій гілці сценарію |
| Content-Disposition | «завантаж як файл» + «ось імʼя» | header(.string(...) | без нього браузер/клієнт може відкрити inline або назвати файл дивно |
| Content-Type | який тип даних усередині | content()(.contentType(...) | клієнтові важливо розуміти, це PNG, PDF чи ZIP |
| (за потреби) Content-Length | розмір тіла | header(.longValue(...) | корисно, якщо ви справді встановлюєте length, але не завжди це потрібно |
| тіло відповіді (bytes) | власне файл | content()(.bytes(...) | інакше ми «перевірили наклейки на коробці, але не коробку» |
Важливо памʼятати одну тонкість: Content-Disposition і Content-Type — це різні частини контракту. Перший означає «як віддавати» — attachment чи inline, а також імʼя файла. Другий — «що всередині». Дуже часта помилка — думати, що Content-Type якось впливає на імʼя файла. Не впливає. Імʼя визначає Content-Disposition.
У навчальному режимі ми можемо точно перевіряти бінарне тіло, тому що використовуємо маленький і повністю детермінований масив байтів. У реальному продукті, якщо файли великі, точне порівняння кожного байта може стати дорогим і безглуздим на цьому рівні. Але для курсу це якраз правильна вправа: ви побачите, що тест здатний зафіксувати реальний вміст відповіді, а не «відчуття, що все гаразд».
3. Production-код: складання file response
Перш ніж писати тест, корисно на хвилину приземлитися і подивитися, як зазвичай виглядає такий ендпойнт усередині контролера. Це не про те, щоб вивчати файлову систему або оптимізацію потоків — ми просто хочемо зрозуміти, звідки беруться заголовки і чому тест має перевіряти саме їх. І ще: це той момент, коли багато хто вперше помічає, що HTTP-відповідь — це не лише @ResponseBody.
Умовна, спрощена модель, яку зручно тримати в голові для MVC-тестів, може виглядати так: контролер викликає сервіс, а сервіс повертає record або клас такого вигляду:
// DTO для результату завантаження: метадані + байти.
// Важливо: імʼя файла беремо саме звідси, а не «вгадуємо» за URL.
public record DownloadedAttachment(
String originalFilename,
String contentType,
byte[] bytes
) {
}
А сам контролер — точніше, шматок логіки складання заголовків — зазвичай виглядає приблизно так:
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
// Складаємо headers явно: так ми контролюємо, як клієнт зберігатиме файл.
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
// Важливо: це "оригінальне" імʼя, яке побачить клієнт під час завантаження.
.filename(originalFilename)
.build());
І поруч — встановлення типу:
import org.springframework.http.MediaType;
// Content-Type — окрема частина контракту: описує тип вмісту, а не імʼя файла.
headers.setContentType(MediaType.parseMediaType(contentType));
Сенс тут простий: імʼя файла приходить не з URL і не з storedFilename, а з метаданих — зазвичай originalFilename. Саме контролер вирішує, що це attachment, а не inline. У тесті ми перевіряємо результат цього складання, не лізучи в деталі того, де сервіс узяв байти: на диску, в хмарі чи в інопланетному порталі.
4. Happy path в @WebMvcTest
У тестах контролера на JSON ми звикли, що «головна перевірка» — це jsonPath. Для завантаження головна перевірка зазвичай починається із заголовків: вони визначають, як клієнт поводитиметься з тілом. Це якраз той випадок, коли тест може бути маленьким, але дуже корисним: три-чотири очікування дають реальний захист від регресій у контракті.
Уявімо, що в нас є EditorArticleController, а його залежність, наприклад EditorAttachmentService, ми підміняємо на @MockitoBean. У happy path ми хочемо зафіксувати: статус 200, коректний Content-Disposition з іменем cover.png і коректний Content-Type — наприклад, image/png.
Спочатку задамо поведінку мока. Зверніть увагу: ми віддаємо крихітний масив байтів. Це важливо — тест має бути швидким і читабельним.
import static org.mockito.BDDMockito.given;
// Мокуємо сервіс: на вході (articleId, attachmentId), на виході — метадані + байти.
given(attachmentService.download(10L, 3L))
.willReturn(new DownloadedAttachment(
"cover.png", // імʼя, яке потрапить у Content-Disposition
"image/png", // значення для Content-Type
new byte[] {1, 2, 3} // маленький детермінований payload для тесту
));
Тепер робимо запит і перевіряємо ключові частини відповіді. Тут я навмисно використовую HttpHeaders.CONTENT_DISPOSITION, щоб не плодити рядкові літерали, які потім «трохи відрізняються й чомусь не проходять».
import org.springframework.http.HttpHeaders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Перевіряємо саме HTTP-контракт: статус + ключові заголовки.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(status().isOk())
// Content-Disposition визначає: «завантажити як файл» і «як назвати файл».
.andExpect(header().string(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"cover.png\""
))
// Content-Type визначає тип вмісту (png/pdf/zip тощо).
.andExpect(content().contentType("image/png"));
Невеликий коментар із життя: рядок attachment; filename="cover.png" виглядає банально, але саме ця дрібниця ламається найчастіше. Хтось змінює складання заголовка, хтось забуває лапки, хтось вирішує, що імʼя має бути storedFilename (а воно раптово стає на кшталт b6f2c9a1-...dat), і ось у клієнта вже біль. Тест ловить це миттєво.
Якщо ви хочете зробити очікування трохи менш крихким, наприклад щоб пережити появу filename*= у майбутньому, можна перевіряти не повну рівність рядка, а важливий фрагмент. Для цього підійде matcher containsString, але тоді ви підключаєте Hamcrest. Це допустимо, просто робіть це свідомо — ми не повертаємося до «Hamcrest як основного стилю», ми лише використовуємо його там, де Spring очікує matcher.
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
// Перевіряємо лише важливий фрагмент: імʼя файла в Content-Disposition.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(header().string(
HttpHeaders.CONTENT_DISPOSITION,
containsString("filename=\"cover.png\"")
));
5. Бінарне тіло: bytes, а не рядки
Після заголовків настає момент, де новачки дуже люблять наступати на граблі: «а давайте порівняємо body як рядок». Це майже завжди погана ідея, тому що файл — це не текст. Навіть якщо у вас PDF, PNG або ZIP, Java дуже чесно спробує інтерпретувати байти як символи, і ви отримаєте тест, який або падає, або зелений, але не доводить нічого корисного.
У нас є два нормальні підходи. Перший — використати content().bytes(...). Він лаконічний і добре читається, коли масив маленький.
// Очікуємо точні байти файла (у навчальному прикладі вони маленькі та детерміновані).
byte[] expected = new byte[] {1, 2, 3};
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(content().bytes(expected));
Другий — забрати байти з відповіді й перевірити їх через AssertJ. Цей варіант іноді дає зрозуміліші повідомлення про помилку та дозволяє робити трохи більш людські перевірки.
import static org.assertj.core.api.Assertions.assertThat;
// Витягуємо тіло відповіді як byte[] (не рядком!), щоб не «зіпсувати» бінарні дані.
byte[] body = mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andReturn()
.getResponse()
.getContentAsByteArray();
// AssertJ дає гарне повідомлення, якщо байти відрізняються.
assertThat(body).containsExactly(1, 2, 3);
Тут важлива дисципліна: не перетворюйте тест на «генератор великих бінарників». Якщо ви спробуєте запхнути в тест 5 мегабайт «як у реальному світі», ви отримаєте повільний і шумний тест, який складно читати. MVC-слайс — це про швидкий зворотний зв’язок. Тому використовуємо маленький payload і доводимо, що механізм працює.
Якщо ваш production-код виставляє Content-Length, його можна зафіксувати окремо. Але робіть це лише тоді, коли ви справді вважаєте це частиною контракту. Іноді Content-Length не виставляється явно або залежить від типу Resource, і тоді краще не прив’язувати тест до того, чого ви не обіцяли.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import org.springframework.http.HttpHeaders;
// Content-Length — опціональна частина контракту: фіксуємо лише якщо справді зобов’язуємося її виставляти.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, 3L));
6. Негативні сценарії: 404 і 400
Файлові ендпойнти ламаються не лише на happy path. Ба більше, саме негативні сценарії тут найчастіше дають користувачеві «дивні» помилки: замість вашого ApiProblem раптово повертається щось не те, і клієнт не розуміє, як це обробляти. Тому ми обов’язково фіксуємо хоча б одну-дві відмовні гілки: «вкладення не знайдено» і «параметр некоректний». Це не спроба покрити всю матрицю світу, а страховка контракту.
Почнімо з «не знайдено». Припустімо, сервіс кидає AttachmentNotFoundException або більш загальний ArticleNotFoundException — неважливо, важлива ідея, — а наш @ControllerAdvice перетворює це на ApiProblem зі статусом 404. У тесті ми мокуємо виняток і перевіряємо, що відповідь справді 404 і в ній є очікувані поля контракту.
import org.springframework.http.MediaType;
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.*;
// Сервіс повідомляє: вкладення не знайдено (контролер/адвайс мають перетворити це на ApiProblem 404).
given(attachmentService.download(10L, 99L))
.willThrow(new AttachmentNotFoundException(99L));
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 99L)
// Явно просимо JSON у помилці, щоб не отримати «випадковий HTML».
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
// Перевіряємо поля ApiProblem, а не лише статус.
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.title").exists());
Зверніть увагу на accept(MediaType.APPLICATION_JSON). Це маленька, але важлива деталь: ми явно кажемо «у разі помилки я хочу JSON». І ми тестуємо саме ваш API-контракт, а не випадковий HTML від типового обробника помилок, який іноді з’являється, якщо десь просочився text/html.
Тепер «поганий параметр». Якщо path variable має тип long, і клієнт надсилає oops, Spring не зможе перетворити це на число. Зазвичай у відповідь буде 400 Bad Request (type mismatch). Це не бізнес-помилка, а помилка формату запиту, і вона теж має бути приведена до вашого ApiProblem.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Передаємо рядок замість числа: очікуємо 400 і стабільний JSON-контракт помилки.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, "oops")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errorCode").exists());
Так, це виглядає трохи смішно: ми тестуємо “oops” як attachmentId. Але саме такі випадки й прилітають у реальний API, коли клієнт помилився, коли фронтендер випадково передав рядок замість числа або коли хтось руками смикнув ендпойнт через curl. MVC-тест має довести: контракт помилки стабільний.
Межа тесту контролера під час завантаження
Після кількох прикладів легко захопитися і почати «доводити все» всередині тесту контролера: як сервіс знайшов файл, як правильно влаштований шлях на диску, як storage читає байти, як воно працює на Windows, Linux і в ретроградному Меркурії. Але MVC-слайс — не про це. Якщо ви спробуєте перетворити тест контролера на перевірку файлової системи, отримаєте дорогий, крихкий і неприємний набір тестів.
Межа тут така сама, як ми вже проговорювали: тест контролера починається на HTTP-вході й закінчується на HTTP-виході. Він перевіряє request mapping, перетворення path variables, те, як результат сервісу перетворено на status/headers/body, і те, як виняток переведено в ApiProblem. Усе, що нижче — реальне читання файла, права на каталоги, гонки за файл під час паралельних запитів, обмеження файлової системи — має перевірятися на інших рівнях і в інші дні курсу.
Навіть interaction-перевірки з моками тут варто використовувати дуже обережно. Одна перевірка на кшталт «контролер справді викликав сервіс із потрібними id» може бути корисною як страховка від переплутаних аргументів. Але щойно ви починаєте перевіряти порядок викликів, додаткові методи й «а от тут він мав зробити рівно три звернення», ви перетворюєте тест на спектакль. Зелений спектакль, який не обов’язково пов’язаний із реальністю.
Якщо хочеться мінімально перевірити межу делегування, це може виглядати так:
import static org.mockito.Mockito.verify;
// Нам не важливі деталі: фіксуємо лише те, що контролер не переплутав аргументи.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L));
verify(attachmentService).download(10L, 3L);
Сенс саме в мінімумі: ми не перевіряємо внутрішності, ми лише фіксуємо, що контролер не «загубив» параметри і справді пішов у залежність.
7. Типові помилки під час завантаження вкладень
Помилка №1: перевіряти лише 200 OK і не дивитися на Content-Disposition.
Такий тест часто виглядає як «ну, завантажилося ж», доки не приходить реальний клієнт — браузер, мобільний застосунок або фронтенд — і не каже: «А чому файл називається attachments і відкривається у вікні замість того, щоб завантажитися?». Content-Disposition — це не косметика, а частина контракту, і краще зловити регресію тестом, ніж баг-репортом.
Помилка №2: плутати імʼя файла і Content-Type.
Новачки інколи очікують, що якщо виставити Content-Type: image/png, то браузер «сам зрозуміє», що імʼя має бути cover.png. Не зрозуміє. Без Content-Disposition імʼя може стати будь-яким або взагалі бути відсутнім, а поведінка клієнта залежатиме від його логіки. У тесті варто перевіряти ці дві речі окремо, як дві незалежні частини відповіді.
Помилка №3: порівнювати бінарну відповідь як рядок.
Якщо ви робите content().string(...) або витягуєте getContentAsString() і порівнюєте з чимось, ви тестуєте не файл, а випадкову інтерпретацію байтів як тексту. Для бінарної відповіді використовуйте bytes і працюйте з масивом byte[]. Це банально, але це одне з найчастіших джерел безглуздих тестів.
Помилка №4: тягнути реальну файлову систему в MVC-слайс.
З’являється спокуса: «а давайте покладемо файл у src/test/resources, а контролер нехай реально читає його через storage». На цьому місці MVC-слайс перестає бути швидким і ізольованим: ви починаєте тестувати storage, конфігурацію шляхів, права та інші I/O-деталі. На рівні контролера нам достатньо, щоб залежність повернула байти, а контролер коректно оформив відповідь.
Помилка №5: не тестувати негативні сценарії і отримати «випадковий» формат помилки.
Файлові ендпойнти часто повертають помилки інакше, ніж JSON-ендпойнти, просто тому, що десь не підключили @ControllerAdvice у slice або тому, що клієнт надіслав інший Accept. Один тест на 404 і один тест на 400 зазвичай уже різко підвищують упевненість: ви заздалегідь фіксуєте, що навіть у ендпойнті завантаження помилки виглядають як ваш ApiProblem, а не як «щось там від сервера».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ