JavaRush /Курси /Spring Test /Похідні запити: тести

Похідні запити: тести

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

1. Похідні запити: коли потрібні тести

Якщо ви вже працювали зі Spring Data, то, мабуть, ловили себе на думці: «Я написав метод з імʼям findBySomethingAndSomethingElse — і воно магічно запрацювало. Отже, тестувати не треба, адже це ж “фреймворк зробив”». У цьому місці фреймворк має привід тихо посміхнутися. Бо згенерований запит — це все одно частина поведінки вашого застосунку, а отже він теж може зламатися.

По-перше, похідний метод — це контракт на читання у вашому домені. У ContentHub такі читання безпосередньо впливають на видимість статей: наприклад, «знайти статтю за slug лише якщо вона опублікована» — це вже безпека і бізнес-сенс, а не «просто запит». По-друге, імʼя методу — це рядок, хоч і типізований через Java-символи, і помилка в назві поля може проявитися лише на старті контексту або під час першого виклику. По-третє, навіть якщо метод правильно зібрався, він може повертати не те, що ви очікуєте, через нюанси даних, сортування, статусів і «майже відповідних» записів.

Щоб не залишатися на рівні філософії, запамʼятаємо робочу думку: ми тестуємо похідні запити не тому, що сумніваємося в Spring Data, а тому, що сумніваємося у нашій постановці задачі та в тому, як вона закодована в імені методу.

Невелика схема того, що ми насправді перевіряємо:

flowchart TD
    A["Імʼя похідного методу як контракт на читання"] --> B["Підготовка контрастних даних (підходить / майже підходить / зайве)"]
    B --> C["flush + clear (робимо поведінку БД видимою)"]
    C --> D["Виклик репозиторію"]
    D --> E["Перевірка: склад + порядок + порожнеча/наявність"]

2. Похідний метод: читаємо імʼя

Термін derived тут означає «похідний». Spring Data бере імʼя методу і виводить з нього запит. Для новачка це виглядає як магія рівня «Гоґвортс для Java», але на практиці це просто угода про імена, яка працює досить передбачувано. І саме через цю передбачуваність її зручно тестувати.

Коли ви бачите імʼя на кшталт findAllByAuthorUsernameAndStatusOrderByUpdatedAtDesc, це не просто довгий рядок, від якого хочеться заплакати. Це цілий набір обіцянок: які поля беруть участь у фільтрації, який тип результату очікується, чи є сортування і яке саме. Тест має перевіряти саме ці обіцянки — ні більше, ні менше.

Ось коротка «шпаргалка» в табличному вигляді, щоб не перетворювати це на список заклинань:

Фрагмент в імені методу Що це означає Що саме має довести тест
findBy... + Optional<T> очікується максимум один результат що за збігу — present, за незбігу — empty
findAllBy... + List<T> очікується список що зайві записи не потрапили, а потрібні потрапили
...And... кілька умов фільтрації що кожна умова реально впливає, а не лише успішний сценарій
OrderBy...Desc/Asc сортування — частина контракту що порядок результатів саме такий, а не «як вийшло»

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

3. Мінімальний контекст ContentHub

Зараз ми не пишемо новий production-код, а читаємо і тестуємо вже наявний. Але щоб приклади були зрозумілими, нам потрібно домовитися, які поля ми використовуємо в запитах. У ContentHub це цілком життєві поля: slug, status, authorUsername, updatedAt. Цього набору вже достатньо і для фільтрації, і для сортування, а для публічної видачі до нього додадуться publishedAt і посторінкові читання.

Нижче — компактний приклад репозиторію, який відображає нашу сьогоднішню тему. Для цієї теми важливі саме сигнатури методів: за ними Spring Data збирає запит, а тест потім доводить, що цей контракт реально виконується.

import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.Repository;

public interface ArticleRepository extends Repository<Article, Long> {

    // Контракт: за (slug + status) повертаємо максимум одну статтю.
    // Важливо для "публічних" читань: slug може збігтися, але статус має підходити.
    Optional<Article> findBySlugAndStatus(String slug, ArticleStatus status);

    // Контракт: фільтрація за автором і статусом + сортування — частина поведінки методу.
    // Тест має доводити і фільтри, і порядок.
    List<Article> findAllByAuthorUsernameAndStatusOrderByUpdatedAtDesc(
            String authorUsername,
            ArticleStatus status
    );
}

Зверніть увагу на дві речі. Перший метод повертає Optional<Article>, і тест має чесно перевіряти «знайшлося/не знайшлося», а не «ну десь там воно має бути». Другий метод повертає список і вже містить сортування в імені (OrderByUpdatedAtDesc), тому перевіряти лише розмір списку — це, мʼяко кажучи, замало. Це як перевіряти, що поїзд приїхав, але не перевіряти, що він зупинився на потрібній станції.

4. Контрастні дані для тесту

Найчастіша причина «марних» repository-тестів — погані тестові дані. Тест виглядає серйозно, анотації правильні, @DataJpaTest сяє як новорічна гірлянда, але по факту ви поклали в базу рівно один запис, зробили запит і здивувалися, що «воно знайшлося». Такий тест перевіряє приблизно нічого. Він радше перевіряє, що Java не забула, як працювати з рядками.

Робоча стратегія проста: для тесту похідного запиту ви маєте створити маленький, але контрастний світ. У ньому обовʼязково мають бути записи, які повинні потрапити до результату, і записи, які мають бути відфільтровані. Причому серед «зайвих» корисно мати «майже відповідні»: такі, які відрізняються рівно одним критерієм фільтрації. Саме вони доводять, що критерій реально працює, а не збігся випадково.

У термінах ContentHub це зручно формулювати так: якщо метод фільтрує за автором і статусом, то в базі мають бути статті того ж автора, але іншого статусу, і статті іншого автора, але того ж статусу. Якщо метод сортує за часом, то мають бути різні значення часу, інакше тест перевірятиме порядок, який «і так один». Загалом тестові дані мають бути такими, щоб репозиторій міг помилитися — і тоді тест це зловить.

5. Приклад № 1: тестуємо findBySlugAndStatus

Зараз зробимо просту, але дуже характерну перевірку: запит за slug і статусом. На перший погляд він здається дивним, адже slug унікальний. Але з точки зору поведінки застосунку це важливий захист: публічне читання за slug не має повернути статтю, яка ще не опублікована. Навіть якщо хтось вгадає slug, ми не хочемо «випадково» показати DRAFT.

Почнемо з каркаса тесту. Він типовий для @DataJpaTest: піднімаємо зріз, автопідключаємо репозиторій і TestEntityManager.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest
class ArticleRepositoryDerivedQueriesDataJpaTest {

    // Репозиторій — те, що тестуємо (derived queries живуть у його сигнатурах).
    @Autowired ArticleRepository repository;

    // EntityManager для підготовки даних (Arrange): persist/flush/clear.
    @Autowired TestEntityManager entityManager;

    @Test
    void findBySlugAndStatus_returnsEmpty_whenStatusDoesNotMatch() {
        // тіло тесту
    }
}

Тепер найважливіше — дані. Щоб не розписувати десятки обовʼязкових полів Article у кожному прикладі, будемо вважати, що у нас є маленька тестова фабрика або хоча б helper-метод, який заповнює «обовʼязковий мінімум» дефолтами. Це нормальна практика: зараз важливо перевіряти поведінку запитів, а не влаштовувати вправу «напиши setTitle 200 разів».

Ось приклад такої фабрики прямо в тесті — так, вона трохи довша за 10 рядків, зате ви пишете її один раз, а не мучите мозок у кожному тесті:

import java.time.Instant;

private Article article(String slug, ArticleStatus status, String author, Instant updatedAt, Category category) {
    Article a = new Article();

    // Поля, які реально беруть участь у derived-запитах із прикладів.
    a.setSlug(slug);
    a.setStatus(status);
    a.setAuthorUsername(author);
    a.setUpdatedAt(updatedAt);

    // "Обовʼязковий мінімум", щоб сутність була валідною для збереження.
    a.setTitle("Тестовий заголовок");
    a.setSummary("Тестовий опис");
    a.setBody("Тестовий текст");
    a.setCategory(category);

    return a;
}

І тепер сам тест. Ідея така: у базі є стаття зі "spring-boot-testing", але вона у статусі DRAFT. Ми запитуємо PUBLISHED і очікуємо порожній результат. Плюс додамо ще одну «зайву» статтю, щоб тест не був «світом з одного запису».

import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void findBySlugAndStatus_returnsEmpty_whenStatusDoesNotMatch() {
    // Підготовка: створюємо категорію і кілька статей, зокрема "майже відповідну".
    Category java = entityManager.persist(new Category("java", "Java"));

    // slug збігається, але статус ні — має бути відфільтровано запитом.
    entityManager.persist(article(
            "spring-boot-testing",
            ArticleStatus.DRAFT,
            "alice",
            Instant.parse("2026-01-10T10:00:00Z"),
            java
    ));

    // Повністю "зайвий" запис, щоб тест не проходив у порожньому світі.
    entityManager.persist(article(
            "jpa-basics",
            ArticleStatus.PUBLISHED,
            "bob",
            Instant.parse("2026-01-11T10:00:00Z"),
            java
    ));

    // Фіксуємо запис у БД і читаємо вже саме з неї, а не з persistence context.
    entityManager.flush();
    entityManager.clear();

    // Дія: викликаємо derived method рівно так, як він викликатиметься у застосунку.
    Optional<Article> result = repository.findBySlugAndStatus("spring-boot-testing", ArticleStatus.PUBLISHED);

    // Перевірка: за незбігу статусу — порожньо.
    assertThat(result).isEmpty();
}

Зверніть увагу, як тут доводиться фільтр за статусом. Ми не намагаємося створити другу статтю з тим самим slug (база б нас справедливо покарала обмеженням унікальності). Натомість ми створюємо ситуацію, де slug збігся, але статус — ні, і перевіряємо, що результат порожній. Такий тест захищає публічне читання від «витоку чернетки», навіть якщо хтось помилиться в коді й почне використовувати не той метод репозиторію.

6. Приклад № 2: OrderByUpdatedAtDesc

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

Головна думка цієї частини: якщо в імені методу є OrderBy..., то перевірка порядку — не «необовʼязковий перфекціонізм», а частина контракту. І тест має бути строгим: не «містить такі-то slug», а «містить у такому-то порядку».

Зробимо контрастні дані. Нам потрібні статті автора "alice" у статусі DRAFT з різним updatedAt, щоб порядок був змістовним. Нам також потрібні «майже відповідні»: статті "alice" іншого статусу і статті іншого автора того ж статусу.

import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void findAllByAuthorAndStatus_ordersByUpdatedAtDesc() {
    // Підготовка
    Category java = entityManager.persist(new Category("java", "Java"));

    // Дві відповідні записи: порядок має визначатися лише updatedAt.
    entityManager.persist(article("a-1", ArticleStatus.DRAFT, "alice", Instant.parse("2026-01-10T10:00:00Z"), java));
    entityManager.persist(article("a-2", ArticleStatus.DRAFT, "alice", Instant.parse("2026-01-12T10:00:00Z"), java));

    // "Майже підходить": той самий автор, але інший статус.
    entityManager.persist(article("a-3", ArticleStatus.PUBLISHED, "alice", Instant.parse("2026-01-13T10:00:00Z"), java)); // зайве за статусом

    // "Майже підходить": той самий статус, але інший автор.
    entityManager.persist(article("b-1", ArticleStatus.DRAFT, "bob", Instant.parse("2026-01-14T10:00:00Z"), java));       // зайве за автором

    // Робимо БД "джерелом істини" для читання.
    entityManager.flush();
    entityManager.clear();

    // Дія
    List<Article> result = repository.findAllByAuthorUsernameAndStatusOrderByUpdatedAtDesc("alice", ArticleStatus.DRAFT);

    // Перевірка: і фільтри, і порядок — частина контракту.
    assertThat(result)
            .extracting(Article::getSlug)
            .containsExactly("a-2", "a-1");
}

Тут тест доводить одразу три речі — і робить це доволі коротко. Він доводить, що фільтр за автором працює (стаття "b-1" не потрапила), що фільтр за статусом працює (стаття "a-3" не потрапила), і що сортування за updatedAt desc справді застосовується (порядок суворо "a-2", потім "a-1"). Якщо хтось змінить метод на OrderByUpdatedAtAsc, або поміняє поле, або раптом прибере сортування, тест упаде відразу і чесно.

7. flush() і clear() — чесність тесту

Після кількох прикладів може зʼявитися думка: «Гаразд, зрозумів, flush і clear — це мантра. Але що насправді буде, якщо їх прибрати?». Чудове запитання, бо саме тут багато data-тестів починають «випадково зеленіти» і вселяти хибну впевненість.

У JPA у вас є persistence context — перший рівень кешу, де EntityManager тримає керовані сутності. Коли ви робите entityManager.persist(article), обʼєкт стає managed. Далі ви можете читати його поля і порівнювати — і це працюватиме навіть тоді, коли база ще не отримала SQL. Багато запитів усе одно підуть у базу, але результати можуть повертатися як ті самі managed-екземпляри, і у вас зʼявляється ризик перевірити «стан обʼєкта в памʼяті», а не факт коректної взаємодії з базою.

flush() змушує Hibernate негайно відправити зміни в базу, а clear() викидає managed-сутності з persistence context. Після clear() ви вже не зможете «випадково» порівняти обʼєкт із самим собою. Ви будете змушені отримати результат із репозиторію та перевіряти його як те, що прийшло з бази. Це додає тесту доказовості.

Якщо хочеться запамʼятати одним реченням, то ось воно: flush робить запис у БД неминучим, а clear робить читання з БД неминучим. У поєднанні це перетворює test slice з імітації на реальну перевірку persistence-границі.

8. Assertions для тестів похідних запитів

У data-тестах легко скотитися до перевірки лише розміру списку. Він швидкий, він зручний, він не вимагає думати… і майже завжди погано захищає від регресій. Якщо метод повернув не ті елементи, але випадково ту саму кількість, тест залишиться зеленим. Якщо метод повернув елементи у неправильному порядку, тест залишиться зеленим. А потім ви довго дивитиметеся на баг у продакшені й казатимете: «Дивно, але тести ж були…».

У тестах похідних запитів найчастіше потрібні два типи перевірок. Перший — перевірка складу результату: конкретні slug (або id) мають бути в результаті, а не просто «щось». Другий — перевірка порядку, якщо порядок є частиною контракту (а у нас він прямо прописаний в імені). Для цього ідеально підходить AssertJ зі extracting(...) та containsExactly(...): він чітко виражає думку «ось конкретно ці значення і саме в такому порядку».

Для Optional важливо не забувати перевіряти обидва світи: isPresent() у позитивному сценарії та isEmpty() у негативному. А якщо ви все ж дістаєте значення, то краще робити це через AssertJ-ланцюжок, щоб не спіймати NoSuchElementException і не перетворити тест на загадку.

Приклад «нормальної» перевірки для Optional виглядає так:

import static org.assertj.core.api.Assertions.assertThat;

// Дія: читаємо за контрактом "slug + status".
Optional<Article> result = repository.findBySlugAndStatus("jpa-basics", ArticleStatus.PUBLISHED);

// Перевірка: спочатку доводимо, що запис узагалі знайдено, і лише потім перевіряємо сенс.
assertThat(result)
        .isPresent()
        .get()
        .extracting(Article::getSlug)
        .isEqualTo("jpa-basics");

Ця перевірка не найкоротша, але вона дуже чесна: спочатку доводимо наявність, потім доводимо сенс результату. І так, це трохи схоже на бюрократію — але бюрократія в тестах зазвичай дешевша, ніж бюрократія після падіння продакшена.

9. Типові помилки під час тестування derived query methods

Помилка № 1: тестувати «один запис у порожній базі» і називати це перевіркою фільтра.
Якщо в базі одна стаття і ви викликаєте findAllByAuthorUsernameAndStatus..., ви майже нічого не довели. Метод міг би ігнорувати статус, ігнорувати автора, повертати взагалі все підряд — і тест усе одно б пройшов. Контрастні дані — це не «краса», а мінімальний спосіб довести, що умови фільтра реально працюють.

Помилка № 2: ігнорувати OrderBy... в імені методу та перевіряти лише склад.
Коли метод обіцяє порядок, а тест його не перевіряє, ви залишаєте лазівку для регресії, яка особливо боляче проявляється у списках. В UI, в API, у «останніх оновленнях» — порядок часто є частиною контракту. Якщо порядок важливий в імені, він важливий і в assertʼах.

Помилка № 3: не робити flush() і дивуватися, що constraint-помилка не зʼявляється, або що поведінка «якась дивна».
JPA вміє відкладати SQL до зручного моменту. У тесті це перетворюється на ефект «помилка проявилася не там, де ми її чекали». flush() фіксує момент істини: якщо база має сваритися — нехай свариться прямо під час підготовки, а не наприкінці тесту десь усередині виклику репозиторію.

Помилка № 4: не робити clear() і випадково перевіряти managed-обʼєкти, а не читання.
Коли ви порівнюєте поля обʼєкта, який щойно зберегли через persist, ви перевіряєте передусім сам обʼєкт. У складних сценаріях це призводить до хибної впевненості: здається, що «ми прочитали з бази», а насправді просто тримали в памʼяті той самий екземпляр. clear() змушує тест справді читати.

Помилка № 5: перевіряти лише hasSize(n) і радіти зеленому тесту.
Розмір — це слабка ознака. Два неправильні елементи теж дають розмір 2. Якщо запит важливий для поведінки застосунку, фіксуйте конкретні значення: slug/id, і за потреби порядок. У цьому сенсі AssertJ-ланцюжки extracting(...).containsExactly(...) — один із найкращих антидотів проти безглуздих зелених тестів.

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