1. Завантаження як HTTP-контракт
Завантаження файлу в backend-застосунок часто сприймають так: «ось зараз десь на сервері зʼявиться файл, і якщо він зʼявився — значить усе добре». Це підхід із серії «працює — не чіпай», який у тестуванні майже завжди призводить до сюрпризів. На рівні шару контролерів нас цікавить інше: як клієнт має надіслати файл і що саме він отримає у відповідь, зокрема й у відмовних сценаріях.
У нашому проєкті ContentHub завантаження вкладення — це endpoint редактора POST /api/editor/articles/{id}/attachments. Клієнт надсилає файл, а API повертає метадані: оригінальне імʼя, розмір, content type, id вкладення та іноді посилання на завантаження. На цьому рівні ми не зобовʼязані (і не повинні) перевіряти, що файл справді записався у файлову систему. Це завдання іншого шару тестів. Наша мета зараз простіша й чесніша: зафіксувати, що HTTP-границя поводиться передбачувано.
Щоб не плутатися, можна тримати в голові просту «таблицю відповідальності» (це корисніше, ніж заучувати 12 анотацій напамʼять):
| Що ми перевіряємо в MVC-тесті завантаження | Чому це web-шар | Що не перевіряємо | Чому це не web-шар |
|---|---|---|---|
| правильний URI та id статті | мапінг контролера | реальний запис на диск | це зона storage/integration |
| імʼя multipart-поля (наприклад, "file") | контракт запиту | реальний шлях зберігання (/data/...) | технічна деталь адаптера |
| Content-Type файлу та базові обмеження | вхідний формат | алгоритм генерації storedFilename | внутрішня логіка сервісу |
| статус відповіді та JSON-тіло | зовнішній контракт | скільки реально вкладень у статті в БД | шар даних/сервісів |
| форма помилки ApiProblem | контракт помилок | безпека/ролі/owner-check | окремий блок безпеки |
Якщо тримати цей поділ, у endpoint завантаження перестає бути «страшна містична аура», і він стає просто ще одним HTTP-сценарієм.
2. Що таке multipart у запиті
Multipart багатьом здається складним, тому що його рідко читають очима. У JSON усе зрозуміло: фігурні дужки, поля, значення. У multipart тіло запиту — це «лист із кількома вкладеннями»: одна частина — файл, інша — можливо, якісь поля форми, і все це упаковано в один запит із межами (boundary). Насправді це просто формат пакування, а не окрема релігія.
Якщо зовсім грубо, HTTP-запит завантаження можна уявити так (дуже спрощено):
POST /api/editor/articles/10/attachments HTTP/1.1
Content-Type: multipart/form-data; boundary=----abc123
------abc123
Content-Disposition: form-data; name="file"; filename="cover.png"
Content-Type: image/png
<байти файлу>
------abc123--
Spring MVC «зʼїдає» цей формат за допомогою multipart-механізму (у Boot він зазвичай вмикається автоматично). Після цього ваш контролер уже не працює з boundary та сирим рядком. Він отримує обʼєкт MultipartFile, у якого є цілком людські методи: getOriginalFilename(), getContentType(), getSize(), isEmpty().
Щоб було простіше уявити маршрут запиту всередині MVC, корисна проста схема:
flowchart TD
A[HTTP multipart/form-data запит] --> B[Розбір multipart / resolver]
B --> C[Метод контролера отримує MultipartFile]
C --> D[Сервіс: перевірка + збереження + метадані]
D --> E[Контролер повертає JSON DTO відповіді]
C -->|виняток| F["@ControllerAdvice -> ApiProblem"]
Для тестів це означає важливу річ: нам не треба створювати реальні файли на диску, щоб перевірити HTTP-границю. Ми створюємо MockMultipartFile (по суті — байти в памʼяті та метадані), надсилаємо його через MockMvc і перевіряємо, що контролер коректно «прочитав лист», а далі або повернув успішний результат, або акуратно оформив відмову.
3. Мінімальний каркас endpoint-а завантаження
Перед тим як тестувати multipart, корисно домовитися про мінімальну форму того, що взагалі робить контролер. Важливо бачити, які параметри приймає метод, куди делегує і яку відповідь формує. Без цього тест буде схожий на ворожіння на кавовій гущі: «я надіслав файл, воно щось відповіло… мабуть, правильно?».
Уявімо спрощений варіант (ідея, а не «єдина правильна реалізація»). Контролер приймає id статті та multipart-поле file, потім делегує до сервісу, який повертає DTO метаданих вкладення:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
class EditorArticleController {
// Сервіс, у якому живе бізнес-логіка upload-а (валідація, збереження, метадані)
private final ArticleAttachmentService attachmentService;
EditorArticleController(ArticleAttachmentService attachmentService) {
// Впроваджуємо залежність через конструктор — зручно для тестів і прозоро за контрактом
this.attachmentService = attachmentService;
}
@PostMapping(
value = "/api/editor/articles/{id}/attachments",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE // Явно фіксуємо: очікуємо multipart/form-data
)
ArticleAttachmentResponse upload(@PathVariable long id,
@RequestPart("file") MultipartFile file) {
// Контролер не «зберігає файл», він лише приймає part і делегує далі
return attachmentService.upload(id, file);
}
}
В успішному сценарії тут видно головне: контролер приймає MultipartFile і делегує далі. Але одного @RequestPart мало, щоб порожнє завантаження само собою перетворилося на коректну 400-помилку. Якщо проєкт вважає порожній файл помилкою саме на web-границі, поруч із контролером потрібен явний guard або validator на file.isEmpty(), який відріже запит до виклику сервісу. На такий варіант і спирається нижче тест із verifyNoInteractions(...).
Відповідь теж тримаємо простою: лише те, що справді корисно клієнту перевірити й показати.
public record ArticleAttachmentResponse(
long id, // Ідентифікатор вкладення (використовується клієнтом, наприклад, для видалення/завантаження)
String originalFilename, // Імʼя, яке надіслав клієнт
String contentType, // MIME-тип файлу за даними multipart
long size // Розмір у байтах
) {}
Помилки, як і раніше, «перекладаються» в ApiProblem нашим @ControllerAdvice. І ось тут починається найцікавіше: upload має кілька типів відмов, і їх важливо відокремлювати одне від одного, тому що для клієнта це різні причини, а для нас — різні тести.
4. Каркас @WebMvcTest для multipart
MVC-слайс із multipart виглядає майже так само, як і звичайний controller-тест. Різниця лише в тому, що замість .content("{json}") ми використовуємо multipart builder і MockMultipartFile. Важливо не забути: слайс має залишатися вузьким. Жодної файлової системи, жодної бази, жодного «а давайте я все це справді збережу». Це курс із тестування шарів, а не конкурс «хто швидше помилково перетворить @WebMvcTest на @SpringBootTest».
Типовий каркас тесту може бути таким:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(EditorArticleController.class)
// У цьому тесті нас цікавить контракт upload-а, а не безпека — фільтри вимикаємо, щоб вони не шуміли
@AutoConfigureMockMvc(addFilters = false)
class EditorArticleControllerUploadWebMvcTest {
// Сервіс ми мокуюємо: slice-тест має перевіряти web-границю, а не внутрішності бізнес-логіки
@MockitoBean
ArticleAttachmentService attachmentService;
}
Тут є один чесний нюанс. Endpoint у нас належить до editor-зони, і в реальному застосунку він майже напевно захищений. Але поки ми фіксуємо саме HTTP-контракт завантаження та відмовні сценарії multipart, фільтри безпеки можуть тільки шуміти. Ми вимикаємо їх у цьому класі, щоб тест відповідав на одне запитання: чи правильно оброблено multipart на web-границі без змішування з перевірками доступу.
5. Успішний сценарій: перевіряємо контракт відповіді
В успішному сценарії є один підступний момент: студент часто тестує лише статус 200 і вважає, що цього достатньо. Для endpoint-а завантаження це особливо небезпечно, тому що клієнту зазвичай критично важливо отримати назад метадані та ідентифікатор. Якщо ми не перевіряємо payload, тест не захищає контракт: хтось змінить поле originalFilename на name — і UI раптом перестане показувати імʼя файлу, а тест залишиться зеленим і задоволеним життям (на відміну від користувача).
Спочатку створимо MockMultipartFile. Він вимагає імʼя multipart-поля (має збігтися з @RequestPart("file")), імʼя файлу, content type і байти.
import org.springframework.mock.web.MockMultipartFile;
// "file" — це імʼя multipart-поля, воно має збігатися з @RequestPart("file") у контролері
MockMultipartFile file = new MockMultipartFile(
"file",
"cover.png", // filename, який «бачить» сервер як original filename
"image/png", // MIME-тип, який клієнт заявляє в multipart
new byte[] {1, 2, 3} // вміст файлу (у тесті достатньо невеликого масиву)
);
Тепер налаштуємо мок сервісу й надішлемо запит. Я навмисно тримаю приклад коротким: ми перевіряємо кілька ключових полів, а не весь JSON «до останньої коми».
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.http.MediaType;
// Налаштовуємо, що сервіс поверне метадані вкладення — це і є контракт успішної відповіді
when(attachmentService.upload(10L, file))
.thenReturn(new ArticleAttachmentResponse(3L, "cover.png", "image/png", 3));
// multipart(...) будує multipart/form-data запит; .file(file) додає part з імʼям "file"
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 10L)
.file(file)
.accept(MediaType.APPLICATION_JSON) // клієнт очікує JSON
)
// Перевіряємо статус і ключові елементи контрактного JSON
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(3))
.andExpect(jsonPath("$.originalFilename").value("cover.png"));
Якщо хочеться додати мінімум «границі» через взаємодію, можна зробити дуже стриману перевірку, що сервіс узагалі було викликано. Але важливо не скотитися в сценарій «я перевірю кожен подих і кожен рух контролера». Це все одно controller test, а не unit-тест оркестрації.
import static org.mockito.Mockito.verify;
// Мінімальна перевірка: контролер справді делегує в сервіс
verify(attachmentService).upload(10L, file);
У реальному проєкті часто замість прямого порівняння file використовують any(MultipartFile.class) і ArgumentCaptor, тому що іноді в контролері відбувається обгортання або адаптація. Для навчального прикладу допустимо перевіряти пряму передачу, якщо контролер справді нічого з файлом не робить.
6. Відмовні сценарії завантаження
З multipart-відмовами є одна методична пастка: хочеться покрити все підряд (тип, розмір, ліміти, кількість вкладень, права, статус статті, фазу місяця). На рівні контролерів це приведе або до хаосу, або до тестів, які перевіряють сервісну логіку. Нам потрібен розумний набір відмов, який справді належить HTTP-границі.
Щоб не заплутатися, корисно заздалегідь розуміти, де саме виникає помилка. Одні помилки трапляються ще «до вашого коду» (Spring не зміг знайти part), інші — у контролері (перевірили isEmpty()), треті — у сервісі (бізнес-валидація, ліміт за розміром/типом/кількістю). MVC-тест може перевіряти всі три, але сенс перевірок буде різний.
Ось компактна таблиця, яка допомагає тримати голову в порядку:
| Сценарій | Де ламається | Що ми хочемо довести тестом | Типовий HTTP-результат |
|---|---|---|---|
| multipart-поля file немає | binding до контролера | стабільний ApiProblem, а не «щось упало» | 400 |
| файл порожній (0 bytes) | контролер/валідація | клієнт отримує зрозумілу відмову | 400 |
| непідтримуваний тип вмісту | сервіс/валідація | помилка переведена в ApiProblem із кодом | 400 |
| файл занадто великий | сервіс/валідація | фіксуємо errorCode=ATTACHMENT_TOO_LARGE | 400 |
| статтю не знайдено | сервіс | правильний клас помилки, не 400 | 404 |
Далі розберемо кілька ключових випадків.
Порожній файл
Порожній файл — напрочуд часта реальна проблема. У цій лекції вважаємо його помилкою саме web-границі: part присутній, але даних немає, і запит відсікається ще до бізнес-логіки. Тому цей сценарій не те саме, що «тип файлу не підтримується» або «файл занадто великий»: там multipart уже прийнято, а тут сам вхід не проходить базову перевірку.
Приклад тесту (ми надсилаємо new byte[0], очікуємо 400 і стабільний формат помилки). Тут я роблю перевірку за errorCode, тому що це найбільш «контрактна» частина помилки. Якщо у вашому ApiProblem для таких помилок використовується violations — можна перевіряти і її, але не перетворюйте тест на енциклопедію всіх полів.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Порожній файл: part присутній, але даних немає
MockMultipartFile empty = new MockMultipartFile(
"file", "empty.png", "image/png", new byte[0]
);
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 10L)
.file(empty)
)
// Очікуємо відмову за контрактом (400 + errorCode)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errorCode").value("ATTACHMENT_EMPTY"));
І важливий момент для границі: якщо порожній файл відсікається на web-границі, сервіс не має бути викликаний. Інакше вийде, що ви «допускаєте» сміття всередину системи.
import static org.mockito.Mockito.verifyNoInteractions;
// Границя: при невалідному multipart на web-границі ми не повинні «пропускати» запит у сервіс
verifyNoInteractions(attachmentService);
Так, це перевірка взаємодії, і ми використовуємо її обережно: не щоб описати сценарій викликів, а щоб зафіксувати границю. Це як табличка «вхід до сервісу за перепустками» — без перепустки не заходьте.
Невірне імʼя multipart-поля
Одна з найприкріших помилок в upload API — коли фронтенд-розробник надсилає файл не в поле "file", а, наприклад, "attachment" або "document". Сервер при цьому цілком чесно каже: «я не бачу потрібної частини запиту». Це не баг фронту і не баг беку, це порушення контракту. І саме тому його вигідно зафіксувати тестом.
З погляду Spring це зазвичай перетворюється на MissingServletRequestPartException. Наша мета — переконатися, що вона перетвориться на нормальний ApiProblem, а не піде клієнту як «500 тому що хтось забув multipart».
// Імʼя multipart-поля невірне: контролер чекає "file", а ми надсилаємо "document"
MockMultipartFile wrongField = new MockMultipartFile(
"document", "cover.png", "image/png", new byte[] {1, 2, 3}
);
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 10L)
.file(wrongField)
)
// Помилка має бути зрозумілою і контрактною (400 + коректна форма ApiProblem)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.title").exists());
Тут ми не перевіряємо точний detail, тому що тексти винятків іноді змінюються під час оновлення Spring. А ось статус і форма payload — це наш контракт.
І знову: сервіс не має бути викликаний, тому що до нього запит узагалі не дійшов.
import static org.mockito.Mockito.verifyNoInteractions;
// До сервісу справа не доходить: Spring не зміг зіставити обовʼязковий part "file"
verifyNoInteractions(attachmentService);
Непідтримуваний Content-Type
Тепер сценарій, де multipart коректний, файл не порожній, але тип файлу не підходить. Наприклад, ви дозволяєте image/png, image/jpeg, application/pdf, а клієнт надіслав application/x-msdownload (це той самий момент, коли backend починає підозрювати, що йому намагаються завантажити «корисну утиліту», яка чомусь називається cover.png.exe).
У MVC-слайсі ми не хочемо перевіряти всю логіку фільтрації типів — це сервісний, правиловий шар. Але ми хочемо довести, що якщо сервіс вважає тип неприпустимим, то клієнт побачить зрозумілу помилку з кодом.
import static org.mockito.Mockito.when;
// multipart коректний, але content type «підозрілий» і заборонений бізнес-правилами
MockMultipartFile exe = new MockMultipartFile(
"file", "cover.exe", "application/x-msdownload", new byte[] {7, 7, 7}
);
// Імітуємо бізнес-помилку сервісу: саме її код має бути відображений в ApiProblem
when(attachmentService.upload(10L, exe))
.thenThrow(new ContentHubException("ATTACHMENT_TYPE_NOT_ALLOWED"));
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 10L)
.file(exe)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value("ATTACHMENT_TYPE_NOT_ALLOWED"));
Зверніть увагу на методичний сенс: ми не «вгадуємо», які типи дозволені. Ми фіксуємо, що за певної бізнес-помилки зовнішній контракт стабільний.
Занадто великий файл
Обмеження за розміром — це друга вічна тема після «не те поле». Причому обмеження бувають двох типів: інфраструктурні (обмеження multipart на сервері) і прикладні (ваше правило «не більше N МБ»). Для controller-layer цікавіший прикладний шар: він має видавати зрозумілу помилку клієнту, навіть якщо інфраструктура в принципі здатна прийняти файл.
Тест можна написати так:
// «Великий файл»: тут ми просто створюємо помітний масив, але не зобовʼязані впиратися в реальні мегабайти
MockMultipartFile big = new MockMultipartFile(
"file", "big.png", "image/png", new byte[5_000_000]
);
// Імітуємо правило сервісу: розмір перевищено, тому викидається доменна помилка
when(attachmentService.upload(10L, big))
.thenThrow(new ContentHubException("ATTACHMENT_TOO_LARGE"));
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 10L)
.file(big)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errorCode").value("ATTACHMENT_TOO_LARGE"));
Тут важлива практична ремарка. У реальному наборі тестів часто не хочеться створювати справді величезні масиви байтів (памʼять, швидкість, усе таке). Можна тримати байти маленькими і просто змусити мок-сервіс викинути потрібну помилку. Сенс тесту не в тому, щоб «реально переповнити сервер», а в тому, щоб контракт помилки залишався стабільним.
Статтю не знайдено
Цей сценарій не суто специфічний для файлу, але він часто спливає поруч із завантаженням. Клієнт намагається завантажити вкладення до статті, якої немає. Це не помилка формату запиту: запит якраз формально коректний. Це помилка відсутності ресурсу, а отже — 404.
// Валідний файл, але невалідний ресурс: статті з таким id немає
MockMultipartFile file = new MockMultipartFile(
"file", "cover.png", "image/png", new byte[] {1, 2, 3}
);
// Імітуємо доменну помилку «ресурс не знайдено» на рівні сервісу
when(attachmentService.upload(999L, file))
.thenThrow(new ContentHubException("ARTICLE_NOT_FOUND"));
mockMvc.perform(
multipart("/api/editor/articles/{id}/attachments", 999L)
.file(file)
)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
Це хороший тест-запобіжник від випадкової деградації: іноді розробник помилково починає повертати 400 «на все підряд», і API стає важко використовувати. Такий тест допомагає не розповзтися.
7. Як не зробити multipart-тести крихкими
Multipart endpoint легко перетворити на тестове болото: «а давайте перевіримо все, аж до 127-го байта». Але MVC slice-тест не має ставати архівом усіх внутрішніх рішень сервісу та storage. Інакше ви отримаєте дорогі, шумні й постійно зламані тести, які не допомагають, а вимагають жертвоприношень щопʼятниці.
Здоровий мінімум для тестів завантаження зазвичай такий: на успішному сценарії ви перевіряєте статус, media type відповіді та кілька ключових полів JSON (id, originalFilename, size). На негативних сценаріях ви перевіряєте статус і errorCode (або violations, якщо це саме помилка валідації), при цьому уникаєте перевірки «гарних повідомлень» і точних текстів винятків. Там, де запит невалідний на web-границі (немає part, порожній файл), ви додатково можете перевірити, що сервіс не викликався — це хороший маркер коректної границі.
Якщо ви відчуваєте, що в кожному тесті копіюється створення MockMultipartFile, можна ввести маленький helper. Головне — щоб він не ховав сенс тесту (щойно helper починає «магічно» будувати весь request, студент перестає бачити, що відбувається).
private static MockMultipartFile png(String filename, byte[] bytes) {
// Хелпер фіксує: part завжди називається "file", а тип — image/png
return new MockMultipartFile("file", filename, "image/png", bytes);
}
І далі тест читається по-людськи: png("cover.png", new byte[] {1, 2, 3}) — і відразу видно, що це саме file-part "file".
8. Типові помилки під час тестування multipart-завантаження
Помилка №1: неправильне імʼя multipart-поля і «чомусь 400».
Найчастіше тести завантаження падають не через бізнес-логіку, а тому що в MockMultipartFile вказали поле "attachment" замість "file". У реальному застосунку це так само ламає контракт: клієнт надіслав «не ту частину листа». Тест має допомагати вам швидко ловити такі речі, а не перетворювати їх на двогодинне відлагодження: «Spring знову щось не зрозумів».
Помилка №2: спроба перевіряти реальну файлову систему в @WebMvcTest.
Іноді рука тягнеться зробити new File("src/test/resources/...") і «по-справжньому завантажити файл». Це робить тест залежним від шляхів, оточення та випадкових особливостей. @WebMvcTest — про HTTP-границю, тому MockMultipartFile майже завжди кращий: він швидкий, детермінований і не привʼязаний до диска.
Помилка №3: змішування різних причин відмови в один тест.
Порожній файл, непідтримуваний тип і занадто великий розмір — це три різні сценарії. Якщо ви в одному тесті робите «порожній і водночас непідтримуваний, і ще великий», ви отримуєте тест, який незрозуміло що доводить. У підсумку він буде червоніти або зеленіти за випадковим пріоритетом перевірок і перестане бути документацією поведінки.
Помилка №4: перевірка внутрішніх взаємодій замість HTTP-контракту.
Перевіряти verify(service).upload(...) іноді корисно, але щойно тест перетворюється на «контролер має викликати метод A, потім B, потім C, і тільки один раз, і в такому порядку» — ви вже тестуєте реалізацію, а не контракт. Такий тест ламається від безпечного рефакторингу і не дає впевненості клієнту API.
Помилка №5: занадто великі байтові масиви в тестах і “чому suite став повільним”.
Створювати new byte[50_000_000], щоб «довести, що файл великий», — це майже завжди зайве. Якщо ви перевіряєте поведінку при ATTACHMENT_TOO_LARGE, часто достатньо невеликого файлу й винятку від мок-сервісу. Великі масиви перетворюють швидкий MVC-suite на міні-бенчмарк памʼяті, а в нас курс усе ж таки про тестування, а не про стрес-тестування ноутбука.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ