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(...) — один із найкращих антидотів проти безглуздих зелених тестів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ