JavaRush /Курси /Spring Test /Зв’язування і

Зв’язування і перетворення у MVC

Spring Test
Рівень 11 , Лекція 3
Відкрита

1. Вступ

Коли з інфраструктурними ефектами запиту все зрозуміло, наступним хорошим стрес-тестом для того самого MVC-style стають зв’язування та перетворення. Проблема тут виникає ще раніше: сервіс може бути ідеально написаний, але до нього запит просто не дійде, якщо рядкові параметри не зв’язалися або не перетворилися.

Коли ви пишете контролер, часто здається, що найважливіше — це бізнес-логіка в сервісі, а параметри запиту нібито «якось прийдуть». Але в реальності клієнти вашого API — фронтенд, мобільний застосунок і навіть Postman у вашого колеги — живуть у світі рядків: ?page=0&size=10&sort=publishedAt,desc. Якщо зв’язування та перетворення працюють не так, як ви очікуєте, то ваш гарний сервіс навіть не буде викликано — запит зламається ще раніше, на межі web-шару.

І саме тому в @WebMvcTest має сенс перевіряти не лише «200 OK», а й те, що параметри коректно зв’язалися та перетворилися на потрібні Java-типи. Це не тестування бізнес-логіки; це перевірка того, що HTTP-вхід справді проходить крізь двері вашого контролера, а не б’ється лобом об табличку «закрито з технічних причин».

Тому тут нас цікавить вузький маршрут: HTTP-рядок → зв’язування/перетворення → виклик сервісу або відмова ще на вході. Ми не йдемо в бізнес-логіку, а перевіряємо, що web-шар чесно розуміє запит.

2. Binding у Spring MVC

Binding у контексті Spring MVC — це процес, коли Spring бере дані з HTTP-запиту та підставляє їх у параметри вашого методу контролера. Це не один «чарівний виклик», а цілий ланцюжок: Spring читає URL, query params, path variables, заголовки, тіло запиту й намагається зібрати з цього значення тих типів, які ви вказали в сигнатурі методу.

Щоб стало простіше, уявіть, що метод контролера — це функція з параметрами, а HTTP-запит — конверт із папірцями. Binding — це момент, коли Spring дістає папірці та акуратно розкладає їх по потрібних комірках. Якщо на одному папірці написано «page = котик», а ви очікуєте int page, то Spring чесно скаже: «Не можу», і до вашого коду справа не дійде.

Нижче — спрощена схема того, де саме це відбувається під час обробки запиту:

flowchart TD
    %% Спрощений ланцюжок обробки запиту у Spring MVC
    A[HTTP-запит] --> B[Spring MVC]
    B --> C[Зв’язування: знайти значення параметрів]
    C --> D[Перетворення: String → потрібний Java-тип]
    D --> E[Виклик методу контролера]
    E --> F[Контролер викликає сервіс]
    F --> G[Формується HTTP-відповідь]

Зверніть увагу на важливу думку: перетворення — це частина binding. Спочатку Spring знаходить рядкові значення (наприклад, "10"), а потім намагається зробити з них int, long, enum, Sort, Pageable тощо.

3. Conversion: рядки в Java-типи

Перетворення — це те місце, де Java-розробник зазвичай каже: «Ну Spring же розумний». І Spring справді розумний, але він не телепат. Він уміє конвертувати багато чого з коробки, але лише тоді, коли ви формулюєте очікування явно й у правильному форматі. Тому перетворення корисно розуміти не на рівні «десь там усередині», а на рівні «які формати я обіцяю клієнту».

Найпростіший приклад: @RequestParam int page. У HTTP усе рядок, отже приходить "0", а Spring має зробити з цього 0. Те саме з @PathVariable long id: приходить "42" — стає 42. Для перелічень (enum) зазвичай приходить рядок "DRAFT" або "IN_REVIEW", і Spring намагається знайти відповідне значення.

Але на практиці в REST API трапляються й «складені» параметри. Класичний приклад із звичайних бекендів — sort=publishedAt,desc. У Spring MVC + Spring Data це може перетворитися на Sort або стати частиною Pageable. І саме тут видно, що перетворення — це вже не «просто розпарсити число», а доволі важлива частина контракту: якщо ви змінили формат сортування, клієнти зламаються, а тести мають сказати вам про це швидко.

Щоб зафіксувати картину, ось невелика таблиця типових конверсій, які трапляються в нашому ContentHub‑API:

Звідки беремо значення Як виглядає в HTTP У що хочемо перетворити в методі контролера Приклад Java‑типу
Параметр запиту ?page=0 номер сторінки int / Integer
Параметр запиту ?size=10 розмір сторінки int / Integer
Параметр запиту (складений) ?sort=publishedAt,desc сортування Sort або частина Pageable
Параметр запиту ?status=IN_REVIEW статус статті ArticleStatus (enum)
Параметр шляху /api/editor/articles/42 id статті long / Long
Параметр шляху /api/public/articles/spring-basics slug статті String

І тепер важливий практичний висновок для тестів: якщо зв’язування або перетворення зламано, ваш контролер може виглядати «ідеально», сервіси — теж, але endpoint перестане бути доступним. Це та сама ситуація, коли «у нас усе працює» закінчується словами «крім API».

4. Binding query params

У ContentHub публічний endpoint списку статей — чудовий тренажер для binding, тому що він майже завжди приймає query params. Навіть якщо сьогодні ви робите найпростіший список, завтра продукт попросить пагінацію, фільтр за категорією, сортування і, як вишеньку на торті, значення за замовчуванням, якщо клієнт нічого не передав.

Припустімо, наш публічний контролер виглядає приблизно так — принаймні за змістом; точні DTO у вас у проєкті вже є з попередніх днів:

import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
class PublicArticleController {

    private final ArticleQueryService articleQueryService;

    PublicArticleController(ArticleQueryService articleQueryService) {
        // Сервіс впроваджуємо через конструктор — це стандартна залежність контролера
        this.articleQueryService = articleQueryService;
    }

    @GetMapping("/api/public/articles")
    PageResponse<ArticleSummaryResponse> listPublished(
            // category — звичайний query param, може бути відсутній
            @RequestParam(required = false) String category,
            // Pageable ми явно не «збираємо»: Spring спробує побудувати його з page/size/sort
            Pageable pageable
    ) {
        // Контролер тут не «мудрує»: просто передає вже сконвертовані аргументи далі
        return articleQueryService.findPublished(category, pageable);
    }
}

Тут для новачка одразу два «підводні камені» — і обидва корисні для навчання: category приходить як рядок, що логічно, а page/size/sort ми взагалі не оголошували, але вони все одно «якось» потрапляють у Pageable. Це і є Spring MVC binding + перетворення в дії: Spring бачить, що ви хочете Pageable, і через вбудовані механізми намагається зібрати його з query params.

Що саме важливо протестувати на цьому endpoint на рівні web-шару? Нам не потрібно перевіряти, що сервіс справді правильно відфільтрував статті за категорією. Ми перевіряємо простішу і більш «web-шарову» річ: що параметри запиту приймаються, перетворюються й передаються в сервіс у правильній формі.

5. Тести зв’язування в @WebMvcTest

Найтиповіша помилка новачка — почати перевіряти зв’язування через складні assert'и по JSON-відповіді, ніби це інтеграційний тест. Виходить боляче: ви прив’язуєтеся до структури відповіді, до серіалізації, до полів DTO, і в підсумку тест падає з будь-якої причини, окрім тієї, заради якої ви його писали.

Набагато спокійніше для голови, а також для підтримки набору тестів, розділити відповідальність: якщо ми тестуємо binding, то головним доказом буде не весь JSON, а факт, що контролер викликав сервіс із правильними аргументами. Це чиста зона відповідальності web-шару: коректно прочитати вхід, перетворити його на параметри та передати далі.

Для такого тесту нам зазвичай достатньо @WebMvcTest, @MockitoBean і ArgumentCaptor. А сам запит зручно писати через MockMvcTester, бо сьогодні ми якраз відточуємо стиль.

Схема залишається тією самою, що й у попередніх сценаріях: явний HTTP-запит, коротка перевірка статусу, а головний сенс — у verify(...) і ArgumentCaptor, тому що саме там видно, як web-layer зрозумів вхід.

Нижче — приклад тестового класу. Зверніть увагу: код навмисно короткий, без зайвих деталей, і він зосереджується на binding, а не на «все й одразу».

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerBindingWebMvcTest {

    @Autowired
    MockMvcTester mvc; // Утиліта для «чистих» запитів до MVC-шару без підняття сервера

    @MockitoBean
    ArticleQueryService articleQueryService; // Мок сервісу, який має отримати вже сконвертовані аргументи

    @Test
    void shouldBindCategoryPageAndSize() {
        // Нам не важлива відповідь як така — важливий факт успішного проходження web-шару до виклику сервісу
        given(articleQueryService.findPublished(any(), any()))
                .willReturn(PageResponse.empty());

        // Надсилаємо query params у «сирому» HTTP-форматі (рядки), а потім перевіряємо, на що вони перетворилися
        assertThat(mvc.get()
                .uri("/api/public/articles?category=java&page=0&size=10")
                .accept(MediaType.APPLICATION_JSON))
                .hasStatusOk();

        // Перехоплюємо Pageable, щоб перевірити результат binding/conversion
        var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
        verify(articleQueryService).findPublished(eq("java"), pageableCaptor.capture());

        // Перевіряємо лише ключові речі: номер сторінки та розмір
        assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(0);
        assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10);
    }
}

Кілька важливих нюансів, які варто проговорити словами, а не «нехай студент сам здогадається». Ми робимо given(...).willReturn(...) не тому, що нам важлива відповідь, а щоб контролер міг успішно завершити обробку й повернути 200. Потім ми перевіряємо, що сервіс викликано з "java", а Pageable справді містить page=0 і size=10. Це і є наш тест зв’язування.

6. Binding sort: publishedAt,desc

Сортування — класичне джерело багів рівня «ніби запит правильний, але чомусь сортується не так». Причому частина проблем навіть не в репозиторії, а в тому, що клієнт передав sort в іншому форматі або контролер очікує інший.

У MVC-slice-тесті ми знову ж таки можемо не порівнювати весь JSON, а перевірити, що Pageable включає потрібний Sort. У Pageable сортування живе всередині, і ми можемо витягнути його та подивитися, що саме вийшло після перетворення.

Ось компактний приклад, який зосереджується на sort:

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@Test
void shouldConvertSortParameterToPageableSort() {
    // Передбачається, що mvc і articleQueryService оголошені так само, як у попередньому прикладі

    // Стаб, щоб контролер дійшов до кінця й повернув 200
    given(articleQueryService.findPublished(any(), any()))
            .willReturn(PageResponse.empty());

    // В HTTP сортування приходить рядком, і саме його conversion ми хочемо перевірити
    assertThat(mvc.get()
            .uri("/api/public/articles?sort=publishedAt,desc")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk();

    // Перехоплюємо Pageable, щоб подивитися, який Sort усередині вийшов
    var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
    verify(articleQueryService).findPublished(any(), pageableCaptor.capture());

    Sort sort = pageableCaptor.getValue().getSort();
    // Перевіряємо напрям сортування для конкретного поля — цього достатньо для binding-тесту
    assertThat(sort.getOrderFor("publishedAt").getDirection())
            .isEqualTo(Sort.Direction.DESC);
}

Тут ми перевіряємо рівно те, що нам потрібно: що рядок publishedAt,desc перетворився на Sort, у якому для поля publishedAt напрямок DESC. Це той випадок, коли тест справді «ловить ризик»: якщо ви випадково зміните параметр у контролері, або перестанете приймати sort, або почнете будувати Pageable вручну й помилитеся, тест упаде.

7. Path variables і помилки перетворення

Binding path variable id

Query params найчастіше ламаються через формати та значення за замовчуванням, а path variables — через більш «прості» причини: хтось змінив шаблон шляху, хтось перейменував змінну, хтось змінив тип id, і в результаті endpoint перестав зіставлятися або перетворюватися.

Перевіряти path variable теж можна на рівні MVC-slice дуже просто. Припустімо, editor endpoint для читання статті виглядає так: GET /api/editor/articles/{id}, і метод контролера приймає long id. Нам важливо, що рядок "42" успішно перетворюється на число, і далі сервіс отримує 42L.

Мініприклад тесту без заглиблення в безпеку та ролі — це окрема площина, а ми зараз говоримо саме про binding:

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

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldBindPathVariableIdToLong() {
    // Стаб відповіді: нам важливо, щоб MVC-шар успішно дійшов до виклику сервісу й повернув 200
    given(editorArticleService.getById(42L))
            .willReturn(ArticleDetailsResponse.stub());

    // У path variable завжди рядок, а ми хочемо long — це й перевіряємо
    assertThat(mvc.get()
            .uri("/api/editor/articles/42")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk();

    // Головне твердження: сервіс отримав уже сконвертований long
    verify(editorArticleService).getById(42L);
}

Сенс тут майже «дитячий», але корисний: якщо хтось у контролері випадково напише @GetMapping("/api/editor/articles/{articleId}") і забуде @PathVariable("articleId") long id, або змінить тип на UUID, а клієнт продовжить надсилати числа, ви зловите проблему на рівні web-тесту дуже швидко.

Некоректний тип параметра: сервіс не викликається

Питання, яке рано чи пізно виникає в кожного студента: «Якщо перетворення не вдалось, що буде?» І правильна відповідь: найчастіше метод контролера просто не викличеться. Тобто ваш @MockitoBean сервіс залишиться без викликів, а Spring поверне клієнту помилку рівня web-шару.

Сьогодні ми не йдемо в повноцінну матрицю помилок, error payload і стратегію @ControllerAdvice — це окремий великий блок, — але одну важливу думку про binding корисно закріпити прямо зараз: некоректний тип параметра — це не «помилка в бізнес-логіці», а «не вдалося прочитати запит».

Тому іноді доречний дуже короткий тест «сервіс не викликається», щоб зафіксувати межу. Наприклад, якщо page має бути числом, а прийшло page=котик, то запит не має випадково дійти до сервісу й почати працювати «якось». Він має впасти на вході.

Такий тест можна написати мінімально і без обговорення формату помилки:

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

import static org.mockito.Mockito.verifyNoInteractions;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldRejectRequestWhenPageIsNotNumber() {
    // Важлива частина тесту: параметр page приходить «поганим» рядком, і conversion має впасти на вході
    assertThat(mvc.get()
            .uri("/api/public/articles?page=котик")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatus4xxClientError();

    // Якщо conversion не вдався, контролер не викликається, отже сервіс узагалі не має брати участь
    verifyNoInteractions(articleQueryService);
}

Тут ми спеціально не стверджуємо конкретний статус і не перевіряємо тіло помилки, щоб не змішувати теми. Нам достатньо зафіксувати, що контролер не буде «наївно» викликаний зі зламаними параметрами, і що сервіс не має брати участь у цьому цирку.

8. Типові помилки в MVC binding-тестах

Помилка №1: намагатися перевіряти binding через повну JSON-відповідь, ніби це інтеграційний тест.
Такий тест виходить крихким: він падає і від зміни DTO, і від зміни серіалізації, і від будь-якого правлення тіла відповіді. У результаті ви втрачаєте фокус: незрозуміло, чи впав тест через binding, чи через усе інше. Для binding набагато надійніше перевіряти аргументи виклику сервісу через verify(...) і ArgumentCaptor.

Помилка №2: «ховати під килим» HTTP-деталі заради краси.
Іноді студенти роблять допоміжний метод, який «сам збирає URL» і приховує, які query params реально надсилаються. У результаті тест читається красиво, але незрозуміло, що саме ми перевіряємо. Для binding-тестів важливо, щоб формат параметрів був видимий прямо в тесті: ?sort=publishedAt,desc, ?page=0&size=10, ?status=IN_REVIEW.

Помилка №3: перевіряти занадто багато деталей Pageable і Sort (надмірна специфікація).
Якщо ви в кожному тесті починаєте перевіряти весь Sort до останньої коми, набір тестів починає ламатися від найменших безпечних змін. Для binding-тесту зазвичай достатньо перевірити одну-дві «несучі» речі: номер сторінки, розмір сторінки, наявність конкретного order’а за ключовим полем.

Помилка №4: забувати про дефолти та вважати, що клієнт завжди надсилатиме всі параметри.
У реальному житті половина клієнтів надсилає GET /api/public/articles без параметрів. Якщо у вас контролер очікує @RequestParam int page без значення за замовчуванням, то на порожньому запиті він може почати падати. Навіть якщо ви не тестуєте дефолтну пагінацію цілком, хоча б один тест «без параметрів» зазвичай окупається: він фіксує, що endpoint доступний і не вимагає від клієнта знати всі внутрішні налаштування.

Помилка №5: змішувати в одному тесті binding, фільтри, обробку помилок і бізнес-логіку.
Така «солянка» особливо популярна, тому що хочеться «одним тестом усе перевірити». Але результат — тест, який складно читати, ще складніше підтримувати, і майже неможливо швидко діагностувати під час падіння. Якщо мета тесту — binding, тримайте його вузьким: запит → статус → аргументи виклику сервісу. Усе інше має жити в тестах свого шару та своєї мети.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ