JavaRush /Курсы /Spring REST & MVC /Multipart и аудит web-layer тестов

Multipart и аудит web-layer тестов

Spring REST & MVC
30 уровень , 4 лекция
Открыта

1. Multipart как отдельный контракт

Multipart‑эндпойнты часто ломаются не потому, что «Spring плохой», а потому, что их контракт буквально состоит из нескольких независимых частей. Это как отправить посылку, где в одном пакете лежит файл, а в другом — бумажка с описанием, и курьер (Spring MVC) очень строго ждёт оба пакета с правильными наклейками. Если вы перепутаете имена частей или типы, всё развалится ещё до бизнес‑логики.

С обычным JSON endpoint’ом всё относительно прямолинейно: один Content-Type: application/json, одно тело, один @RequestBody. Multipart — это multipart/form-data и набор parts. У каждого part есть имя (например, "file" или "metadata"), и у него тоже есть свой Content-Type. И если вы думаете «ну это же просто один запрос», то Spring думает иначе: «это два (или больше) независимых фрагмента данных, которые нужно по-разному распарсить и связать с параметрами метода».

Ещё одна причина, почему multipart нужно тестировать отдельно: очень легко случайно «починить» endpoint в Postman’е, а потом понять, что тесты не умеют воспроизводить тот же запрос. Или наоборот: тест «как-то собрал multipart», но клиент (Postman/Frontend) отправляет иначе, и вы получаете сюрприз в виде 400 Bad Request из-за отсутствующей части.

Поэтому здесь логика простая и довольно взрослая: раз multipart — это отдельная форма контракта, значит, мы должны иметь отдельные web-layer тесты, которые проверяют именно multipart‑контракт, а не «что-нибудь про вложения в целом».

До этого мы гоняли через один и тот же MVC slice обычные JSON endpoint’ы. File-сценарии не требуют другого тестового режима: меняется только форма контракта. Вместо одного body появляются parts, вместо обычного JSON иногда едут байты и заголовки, но проверяем мы всё тот же внешний HTTP-ответ, который видит клиент.

2. Контракт upload endpoint’а: parts и ответ

Перед тем как писать тест, полезно на минуту остановиться и честно проговорить контракт upload endpoint’а словами. В Task Tracker API вложение — это подресурс задачи, и загрузка идёт через POST /api/v1/tasks/{taskId}/attachments. Запрос multipart содержит как минимум две части: бинарный файл (file) и JSON-метаданные (metadata). И вот это «как минимум» — не художественная формулировка, а часть договора с клиентом.

Представьте запрос как «псевдо‑контракт» (не RFC, а просто ясная картинка):


POST /api/v1/tasks/{taskId}/attachments
Content-Type: multipart/form-data

Part "metadata" (Content-Type: application/json):
{"description":"Sprint note"}

Part "file" (Content-Type: text/plain):
<bytes of note.txt>

На уровне Spring MVC это обычно выражается так: @RequestPart("metadata") для JSON‑части и @RequestPart("file") для файла. Обратите внимание: мы используем именно @RequestPart, а не @RequestParam, потому что «часть multipart» — это не query‑параметр и не просто «строка в форме», а отдельный кусок тела.

Мини‑пример сигнатуры контроллерного метода (логика внутри намеренно максимально простая):

import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
class AttachmentController {

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    AttachmentResponse upload(@PathVariable String taskId,
                              // JSON-часть multipart: Spring прочитает байты и сконвертирует в DTO через Jackson
                              @RequestPart("metadata") @Valid AttachmentUploadMetadataRequest metadata,
                              // Файловая часть multipart: сюда попадёт бинарный контент + имя файла + content type
                              @RequestPart("file") MultipartFile file) {
        // В web-layer тесте мы проверяем, что контроллер корректно принимает parts и отдаёт HTTP-ответ.
        return attachmentService.upload(taskId, metadata, file);
    }
}

В ответе мы ожидаем 201 Created и JSON с метаданными вложения (AttachmentResponse). Клиенту обычно важно увидеть хотя бы идентификатор вложения и оригинальное имя файла (и, да, это тот случай, когда «имя файла» — реально часть контракта, а не эстетика).

Если вы сейчас подумали: «Серьёзно? Мы тестируем имя части "metadata" и "file"?» — да, серьёзно. Для клиента это примерно то же самое, что название поля в JSON. Переименовать part — это почти как переименовать title в taskTitle: серверу-то не сложно, а клиенту — боль.

3. Сборка multipart-запроса в MockMvc

Теперь к практической части: как воспроизвести multipart запрос в MockMvc. Тут есть маленькая инженерная радость: вам не нужен реальный файл на диске и не нужен настоящий браузер. Spring даёт MockMultipartFile, который можно создать из массива байт, задав имя части, оригинальное имя файла и Content-Type. Это такой «бутерброд для теста»: не настоящий обед, но по составу очень похож, и желудок контроллера его переварит.

Важная мысль: part metadata — это тоже MockMultipartFile. Да, звучит слегка странно (“JSON как файл?”), но технически multipart part — это набор байт. Если вы хотите, чтобы Spring прочитал его через Jackson и собрал ваш AttachmentUploadMetadataRequest, вы должны дать part правильный Content-Type: application/json и валидный JSON внутри.

Практически удобно иметь в тесте ObjectMapper, чтобы не склеивать JSON вручную и не устраивать себе «квест по кавычкам». Мини‑пример создания частей:

import static java.nio.charset.StandardCharsets.UTF_8;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.mock.web.MockMultipartFile;

// Файловая часть: имя part = "file" (должно совпасть с @RequestPart("file"))
MockMultipartFile file = new MockMultipartFile(
        "file",
        "note.txt",     // оригинальное имя файла (часть контракта)
        "text/plain",   // content type файла
        "hello".getBytes(UTF_8)
);

// JSON-часть: технически это тоже набор байт, просто с content type application/json
MockMultipartFile metadata = new MockMultipartFile(
        "metadata",
        "", // оригинальное имя файла для JSON-части обычно не нужно
        "application/json",
        // Генерируем JSON через ObjectMapper, чтобы не ошибиться с кавычками/экранированием
        objectMapper.writeValueAsBytes(new AttachmentUploadMetadataRequest("Sprint note"))
);

Обратите внимание на пару деталей. Имя части "file" и "metadata" должно совпадать с тем, что ждёт контроллер. Второй аргумент (оригинальное имя файла) для metadata можно оставить пустым — это не файл в пользовательском смысле. И ещё момент: multipart(...) в MockMvc сам выставит общий Content-Type: multipart/form-data с boundary, вам не нужно руками придумывать границы, как в студенческих лабораторных по сетям.

Если хочется совсем коротко показать, что происходит, можно думать об этом так:

flowchart LR
    T[Test] --> M[MockMvc multipart request]
    M --> D[DispatcherServlet]
    D --> C[AttachmentController]
    C --> S["AttachmentService (mock)"]
    S --> C --> M --> A[Assertions]

Именно поэтому в multipart‑тесте мы сосредотачиваемся на том, что приходит в контроллер и что из контроллера уходит наружу, а не на том, как «реально сохраняется файл на диск».

4. Happy-path тест: upload и 201 Created

Happy‑path тест для upload endpoint — это ваш «якорь» контракта. Он должен показать будущему читателю (и вашему будущему “я‑из‑прошлого”), что именно мы считаем правильным multipart запросом и каким должен быть успешный ответ. И да, тут полезно быть немного занудой: если вы не проверите хотя бы ключевые поля ответа, тест превратится в «проверку статуса ради статуса».

Начнём со скелета теста. В реальном проекте он выглядит примерно так: @WebMvcTest(AttachmentController.class), MockMvc, ObjectMapper и замоканный сервис. Пример каркаса (очень минимально):

@WebMvcTest(AttachmentController.class)
class AttachmentControllerWebMvcTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;

    // Сервис мокается: web-layer тест проверяет HTTP-контракт, а не бизнес-логику
    @MockitoBean AttachmentService attachmentService;
}

Теперь happy‑path. Мы заранее настраиваем сервис (stubbing), чтобы он вернул заранее известный AttachmentResponse. А затем собираем multipart и проверяем контракт ответа.

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.ArgumentMatchers.*;
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.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;

@Test
void uploadAttachment_returns201AndMetadata() throws Exception {
    // Настраиваем мок сервиса: контроллер должен просто прокинуть входные данные и вернуть ответ
    when(attachmentService.upload(eq("task-1"), any(), any()))
            .thenReturn(new AttachmentResponse("att-1", "note.txt")); // поля сокращены

    // Файловая часть multipart
    MockMultipartFile file = new MockMultipartFile(
            "file",
            "note.txt",
            "text/plain",
            "hello".getBytes(UTF_8)
    );

    // JSON-часть multipart: content type принципиален, иначе Spring/Jackson могут не распарсить DTO
    String metadataJson = """
            {"description":"Sprint note"}
            """;
    MockMultipartFile metadata = new MockMultipartFile(
            "metadata",
            "",
            "application/json",
            metadataJson.getBytes(UTF_8)
    );

    mockMvc.perform(
                    multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
                            // Важно: part name должен совпасть с @RequestPart("file") / @RequestPart("metadata")
                            .file(file)
                            .file(metadata)
            )
            // Проверяем базовый контракт: статус + JSON
            .andExpect(status().isCreated())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            // Проверяем ключевое поле ответа, на которое опирается клиент
            .andExpect(jsonPath("$.originalFileName").value("note.txt"));
}

Здесь важно, что тест не проверяет «всё и сразу». Он фиксирует базовое обещание endpoint’а: успешная загрузка возвращает 201, ответ — JSON, и в JSON есть хотя бы originalFileName, совпадающее с тем, что мы отправили. В реальном проекте вы, конечно, добавите проверки id, contentType, size, uploadedAt, но подход остаётся тем же: проверяем публичный контракт, а не внутреннюю реализацию.

Если ваш upload endpoint добавляет Location, это отличный кандидат на проверку: andExpect(header(...)). Но не превращайте тест в роман на 40 страниц: лучше несколько коротких тестов, чем один «эпос про всё».

5. Negative-path тесты: missing part, 415, 409

В multipart‑мире negative‑path сценарии особенно важны, потому что часть ошибок возникает раньше сервиса, а часть — уже внутри бизнес‑логики. И если всё это свалить в одну кучу, вы получите «оно иногда 400, иногда 415, и никто не знает почему». Зрелый API обязан быть предсказуемым: одинаковый класс ошибки должен давать одинаковый статус и одинаковый ProblemDetail‑формат.

Самый обязательный негативный кейс multipart — отсутствие обязательной части. Например, клиент отправил file, но забыл metadata. Для нас это не «ну ладно, загрузим без описания», потому что по контракту part должен быть. И тест должен это зафиксировать.

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;

@Test
void uploadAttachment_withoutMetadata_returns400Problem() throws Exception {
    // Отправляем только file-part: намеренно нарушаем контракт (нет обязательного metadata)
    MockMultipartFile file = new MockMultipartFile(
            "file",
            "note.txt",
            "text/plain",
            "hi".getBytes(UTF_8)
    );

    mockMvc.perform(
                    multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
                            .file(file)
            )
            // Ожидаем предсказуемую ошибку уровня web-layer
            .andExpect(status().isBadRequest())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
            // Проверяем машинно-читаемый код (а не текст detail)
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"));
}

Такой запрос ломается на уровне web-layer: обязательной части нет, значит до бизнес‑логики дело не доходит. И это тот же стиль ошибок, что и у обычных JSON endpoint’ов: application/problem+json, machine-readable code и никакого отдельного «файлового формата» отказов.

Поэтому здесь логично проверять code, а не весь текст detail. code — machine-readable часть контракта. Текст может поменяться, локализоваться или уточниться, и это нормально.

Для Task Tracker API удобно держать такой канон:

если отсутствует обязательный metadata part или JSON-метаданные не собираются в DTO, ждём 400 INVALID_INPUT;

если тип файла не поддерживается политикой API, ждём 415 UNSUPPORTED_FILE_TYPE;

если upload запрещён предметным правилом, например для архивной задачи, ждём 409 FILE_UPLOAD_NOT_ALLOWED;

если не найдена родительская задача или само вложение, ждём 404 TASK_NOT_FOUND / ATTACHMENT_NOT_FOUND.

Так negative-path для вложений остаётся предсказуемым: часть ошибок рождается ещё в MVC-пайплайне, часть — уже в сервисе, но снаружи клиент всё равно видит один и тот же ProblemDetail-формат.

Ключевой смысл этого блока: multipart endpoint должен быть таким же «взрослым» в ошибках, как и обычный JSON endpoint. У него просто больше причин ошибиться, но контракт ошибок всё равно должен оставаться единым и машинно‑читаемым.

6. Download endpoint: заголовки и байты

Download endpoint — это почти противоположность upload endpoint’а. Если upload — это «сложный вход», то download — это «строгий выход». Клиент приходит за бинарными данными, и если вы забудете заголовки, клиент может начать показывать мусор в браузере, скачивать файл с именем download без расширения или вообще не понять, что вы ему отдали. Поэтому здесь тестировать нужно не только статус.

Для GET /api/v1/tasks/{taskId}/attachments/{attachmentId}/download базовый контракт обычно включает 200 OK, корректный Content-Type (или fallback application/octet-stream) и Content-Disposition, чтобы браузер/клиент скачивал файл с правильным именем. И, конечно, тело ответа должно содержать байты файла.

В @WebMvcTest мы не читаем реальный файл. Мы делаем проще и честнее: сервис (mock) возвращает заранее известный набор байт и метаданные, а контроллер формирует правильный HTTP‑ответ. Мини‑пример:

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test; 
import org.springframework.http.HttpHeaders;

@Test
void downloadAttachment_setsHeadersAndBody() throws Exception {
    // Мокаем сервис: он возвращает имя файла, content type и байты
    when(attachmentService.download("task-1", "att-1"))
            .thenReturn(new AttachmentContent(
                    "note.txt",
                    "text/plain",
                    "hello".getBytes(UTF_8)
            )); // условный DTO

    mockMvc.perform(get("/api/v1/tasks/{t}/attachments/{a}/download", "task-1", "att-1"))
            // Статус: успешная выдача файла
            .andExpect(status().isOk())
            // Заголовок, от которого зависит поведение браузера/клиента при скачивании
            .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"note.txt\""))
            // Контент: должны совпасть именно байты
            .andExpect(content().bytes("hello".getBytes(UTF_8)));
}

Да, здесь есть «условный DTO» AttachmentContent — это просто иллюстрация, что сервис должен вернуть контроллеру три вещи: имя файла, content type и байты. В вашем реальном проекте это может быть record, доменная модель или отдельный response‑объект, но контроллеру всё равно нужно собрать из этого HTTP‑ответ.

Обратите внимание на Content-Disposition. В нём легко ошибиться с кавычками и пробелами. И вот это как раз тот случай, когда тест спасает от «ну вроде работает». Потому что «вроде работает» обычно заканчивается на первом клиенте, который скачивает файл с именем note.txt"; rm -rf /… Ладно, шутка. (Почти.) Но заголовки действительно нужно формировать аккуратно.

7. Финальный аудит web-layer suite: карта покрытий

Финальный аудит тестов — это не подсчёт количества методов с @Test. Если вы просто набили 50 тестов, API не стало лучше. Качество web-layer suite определяется тем, защищает ли он публичное HTTP‑поведение: статусы, заголовки, shape JSON и shape ошибок. Другими словами, если завтра кто-то «слегка» поменяет контроллер, ваши тесты должны поднять тревогу именно там, где контракт реально ломается.

Удобно делать аудит через «карту покрытий». Например, в виде таблицы, где каждая зона API имеет минимум один happy‑path и минимум один representative negative‑path. Не обязательно тестировать каждую микроскопическую ветку логики — это уже уровень сервиса. Но web‑контракт должен быть защищён.

Ниже пример такой карты для канонического Task Tracker API (как ориентир, а не как бюрократический документ):

Зона API Что считаем базовым happy-path Что считаем базовым negative-path На что смотрим в ответе
tasks (list/detail/create/update/delete) GET /tasks возвращает envelope PagedResponse, POST /tasks возвращает 201 validation 400, malformed JSON 400, TASK_NOT_FOUND 404, конфликт 409 статус, Content-Type, ключевые JSON поля, Location для create (если есть), ProblemDetail.code
comments как подресурс POST /tasks/{id}/comments возвращает 201 TASK_NOT_FOUND 404, validation 400 статус, JSON формы, ProblemDetail при ошибке
attachments (upload/list/detail/download/delete) upload 201, download 200 с заголовками missing part 400, запрет upload 409, неподдерживаемый тип 415, not found 404 статус, Content-Type, Content-Disposition для download, ProblemDetail.code

А теперь главный смысл аудита: видите, что multipart и download здесь стоят наравне с остальными зонами. Это не «опциональная фича». Это часть вашего публичного контракта. Если вы их не тестируете, вы оставляете большой кусок API без страховки.

Есть ещё одно важное правило хорошего финального набора: тесты должны быть читаемыми. Если ваши multipart тесты превратились в 60 строк с builder‑цепочками и копипастой JSON, вы их начнёте бояться. А когда тестов боятся — их не чинят, их отключают. Поэтому держите тесты маленькими, сценарными и максимально «клиентскими» по стилю: читаю URI, вижу метод, вижу ожидаемый статус и вижу ключевые поля ответа.

8. Типичные ошибки при тестировании multipart и file endpoint’ов

В финале зафиксируем грабли, на которые наступают почти все, включая людей, которые «всё уже видели». Тесты на multipart и download выглядят простыми, но именно в них живут самые раздражающие баги уровня “ну почему оно 400, я же всё отправил”.

Ошибка №1: перепутать имена parts и тестировать «не тот контракт».
Multipart‑запрос держится на именах частей. Если контроллер ждёт @RequestPart("metadata"), а вы в тесте отправили "meta", Spring не «догадается», что вы имели в виду. Получите 400. Это не каприз, это и есть контракт. Поэтому в тестах part names должны быть такими же заметными, как URI и метод.

Ошибка №2: отправлять JSON‑метаданные без Content-Type: application/json.
Если metadata part имеет text/plain, Jackson не обязан пытаться его читать как JSON. В результате вместо красивого AttachmentUploadMetadataRequest вы получаете ошибку конверсии или missing part сценарий. В тесте всегда явно задавайте application/json для JSON‑части, иначе вы проверяете не свой API, а случайность.

Ошибка №3: пытаться через @WebMvcTest проверить реальную файловую систему.
@WebMvcTest — это тест web‑контракта, не интеграционный тест хранилища. Как только вы начинаете создавать реальные файлы, вы тащите нестабильность окружения и побочные эффекты. Файловую систему (если вам очень нужно) тестируют на другом уровне. Здесь же сервис замокан — и это нормально, даже если душа просит «настоящести».

Ошибка №4: проверять download только по статусу и игнорировать заголовки.
Download endpoint без Content-Disposition — это как «пицца без коробки»: формально еда есть, но дальше неудобно и грязно. Клиентское поведение зависит от заголовков, поэтому тест должен проверять Content-Type и Content-Disposition. Иначе вы легко “сломаете” скачивание, не заметив.

Ошибка №5: в negative-path тестах привязываться к тексту detail, а не к code и статусу.
Текст ошибок можно улучшать и менять, это нормально. Если ваши тесты проверяют «ровно вот эту фразу», вы сами себе перекрываете кислород для улучшения сообщений. Гораздо стабильнее проверять status, contentTypeCompatibleWith(application/problem+json) и $.code, а для validation — ещё и форму fieldErrors.

1
Задача
Spring REST & MVC, 30 уровень, 4 лекция
Недоступна
Multipart upload через `MockMultipartFile`
Multipart upload через `MockMultipartFile`
1
Задача
Spring REST & MVC, 30 уровень, 4 лекция
Недоступна
Download endpoint с бинарным телом и заголовками
Download endpoint с бинарным телом и заголовками
1
Опрос
Тесты контроллеров, 30 уровень, 4 лекция
Недоступен
Тесты контроллеров
Web-слой и MockMvc
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ