1. Пагінація репозиторію і детермінізм тестів
Пагінація на рівні репозиторію здається нудною рівно до першого бойового середовища, де у вас 30 000 статей, а публічний список має показувати по 20. Якщо в цей момент ви не контролюєте сортування і розбиття на сторінки на рівні БД, застосунок починає поводитися «містично»: між сторінками з’являються дублікати, частина записів зникає, а для однакового запиту результати щоразу різні. І це не баг «в інтерфейсі», а саме поведінка шару даних.
У ContentHub пагінація — частина контракту публічного читання: GET /api/public/articles має віддавати сторінку опублікованих статей у передбачуваному порядку. На вебшарі ми вже вміємо тестувати розбір query-параметрів і формат відповіді, але тут ризик інший. Сьогодні нас цікавить саме шар репозиторію: що саме БД віддає при Pageable і Sort, а також наскільки стабільним є цей результат.
Терміни: Pageable, Page, Sort
Перед тим як писати тести, корисно домовитися про терміни, інакше в голові утвориться каша: «page» як «сторінка UI», «page» як Page<T>, «page» як номер сторінки — і все це в одному реченні. У Spring Data все доволі логічно: Pageable описує запит на сторінку, Sort описує порядок, а Page<T> — результат, у якому є і елементи, і метадані. Тому тест репозиторію має перевіряти обидві частини: не лише «які статті прийшли», а й «чи це справді перша або друга сторінка, і скільки всього елементів».
Нижче — компактна таблиця, яка допомагає пам’ятати, що саме можна і треба перевіряти в тестах. Це не чек-лист із вимогою «перевірити все», а радше карта того, що взагалі існує, щоб ви обирали осмислено.
| Що в Page<T> | Як отримати | Що доводить у тесті |
|---|---|---|
| Вміст сторінки | page.getContent() | Що фільтрація або сортування справді повернули потрібні елементи |
| Розмір сторінки | page.getSize() | Який size ви запросили через PageRequest |
| Скільки елементів на цій сторінці | page.getNumberOfElements() | Що остання сторінка може бути неповною |
| Номер сторінки | page.getNumber() | Що ви не переплутали PageRequest.of(page, size, ...) |
| Загальна кількість елементів | page.getTotalElements() | Що частина підрахунку пагінації коректна |
| Загальна кількість сторінок | page.getTotalPages() | Що розбиття на сторінки відповідає очікуванням |
| Прапорці навігації | page.isFirst(), , |
Що поведінка сторінки відповідає її позиції |
2. Як PageRequest стає LIMIT/OFFSET
Якщо ви досі сприймали PageRequest як «ну… якусь штуку, яку вимагає метод», саме час зробити її зрозумілою. Пагінація в класичному SQL найчастіше означає дві речі: обмежити кількість рядків (limit) і зсунутися на потрібну позицію (offset). У Spring Data логіка зазвичай така: offset = pageNumber * pageSize, де pageNumber — з нуля. Так, нульова сторінка — це нормально. Як нульовий поверх у програмуванні: у житті дивно, у коді звично.
Зручно тримати в голові маленьку формулу на побутовому рівні. Якщо PageRequest.of(0, 20) — це «перші 20», то PageRequest.of(1, 20) — це «наступні 20», тобто зміщення 20. А PageRequest.of(2, 20) — це зміщення 40. Жодної магії: просто математика, яку приємно контролювати тестом.
Невелика схема, щоб візуально не губитися:
flowchart LR
A["PageRequest(page=1, size=2, sort=publishedAt desc)"] --> B["Spring Data будує запит"]
B --> C["SQL: ORDER BY ... LIMIT 2 OFFSET 2"]
C --> D[(DB)]
D --> E["Page<Article>: вміст + метадані"]
Зверніть увагу на важливий момент: без ORDER BY LIMIT/OFFSET перетворюється на лотерею. База даних не зобов’язана повертати рядки в «зручному» порядку. Тому наступний розділ присвячений детермінізму.
3. Детерміноване сортування і стабільний порядок
У пагінації є прихований лиходій: не «неправильна сторінка», а невизначений порядок. Якщо сортування не задане, база може повертати рядки в різному порядку між запусками. А навіть якщо «зазвичай» вона повертає їх однаково, це не контракт. Це випадковий збіг, як якби ваш код «зазвичай» працював без тестів — аж до першого релізу.
І навіть коли сортування задане, є другий, хитріший лиходій: неунікальне поле сортування. Наприклад, ви сортуєте статті за publishedAt desc, але дві статті опубліковані в одну й ту саму секунду (а в тестових даних таке трапляється легко). Тоді порядок між ними стає невизначеним. Рішення просте й дуже практичне: додаємо вторинне сортування, найчастіше за id. Це робить сортування стабільним.
Приклад того, як ми зазвичай задаємо сортування для публічної стрічки ContentHub: спочатку «найсвіжіші за часом публікації», а якщо час однаковий — «вище має бути запис із більшим id».
import org.springframework.data.domain.Sort;
// Стабільне сортування для пагінації: спочатку за бізнес-полем...
Sort sort = Sort.by("publishedAt").descending()
// ...а потім tie-breaker за унікальним полем, щоб порядок був детермінованим
.and(Sort.by("id").descending());
Тут важлива деталь: у Sort.by("publishedAt") ми використовуємо імена полів entity, а не імена колонок. Тобто publishedAt, а не published_at. Якщо помилитися, тест упаде, і це навіть добре: буде чесний зворотний зв’язок.
4. Підготовка тестових даних для пагінації
Пагінація погано тестується «на двох записах». Це як тестувати ліфт, натиснувши кнопку на першому поверсі й радіючи, що двері відчинилися. Щоб довести, що сторінка справді сторінка, даних має бути більше, ніж розмір сторінки. А щоб довести сортування, дані повинні відрізнятися за полем сортування і містити хоча б один «майже однаковий» випадок.
У цьому модулі курсу ми свідомо тримаємо налаштування простими: створюємо кілька Article з потрібними статусами, зберігаємо їх через TestEntityManager, потім виконуємо flush() і clear(), і лише після цього викликаємо метод репозиторію. Ця послідовність — ваш маленький ритуал чесності: «Я не хочу випадково протестувати persistence context замість бази».
Наприклад, нам потрібні опубліковані статті з різними датами публікації. У тесті зручно фіксувати час константами через Instant.parse("2026-03-20T10:00:00Z"), а не Instant.now(). now() у тестах — це швидкий шлях до «у мене локально пройшло, а в колеги впало». Час у тестах має бути нудним. Нудний час — стабільні тести.
Невеликий допоміжний метод, щоб не розмазувати конструктори й сетери по всьому тесту (і так, це саме допоміжний метод для тестів, а не «нова архітектура проєкту»):
import java.time.Instant;
private Article published(String slug, Instant publishedAt) {
// Створюємо мінімально потрібну сутність для тесту: лише те, що впливає на фільтр і сортування
Article a = new Article();
a.setSlug(slug);
a.setStatus(ArticleStatus.PUBLISHED);
a.setPublishedAt(publishedAt);
return a;
}
А тепер — приклад мінінабору даних: 5 статей, щоб при size=2 у нас було 3 сторінки і було що перевірити.
// Даних має бути більше, ніж size сторінки — інакше ми не побачимо реального розбиття на сторінки
entityManager.persist(published("jpa-tips", Instant.parse("2026-03-20T10:00:00Z")));
entityManager.persist(published("spring-boot-4", Instant.parse("2026-03-19T10:00:00Z")));
entityManager.persist(published("testing-slices", Instant.parse("2026-03-18T10:00:00Z")));
entityManager.persist(published("dto-contracts", Instant.parse("2026-03-17T10:00:00Z")));
entityManager.persist(published("mockito-basics", Instant.parse("2026-03-16T10:00:00Z")));
// Важливо: примусово записуємо зміни в БД і очищаємо persistence context,
// щоб читання йшло «як у реальності», а не з managed-об’єктів у пам’яті
entityManager.flush();
entityManager.clear();
У цих 7 рядках заховано дві важливі звички: даних більше, ніж містить сторінка, і читання після flush/clear справді йде з БД.
5. Page-тест: content і метадані сторінки
У тестах пагінації легко впасти в одну з двох крайнощів. Перша — перевірити лише hasSize(2) і радіти, поки не з’ясується, що це «будь-які 2 записи з будь-яких 200». Друга — перевіряти все підряд: кожне поле кожного об’єкта і всі прапорці сторінки — і отримати тест на 120 рядків, який страшно чіпати. Наша мета посередині: перевіряємо найцінніше і найкрихкіше, тобто склад і порядок content та ключові метадані (totalElements, totalPages, номер сторінки).
Для прикладу візьмемо простий похідний метод репозиторію, який уміє повертати сторінку:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
// Репозиторій повертає Page<T>, щоб ми могли перевіряти і content, і метадані пагінації
interface ArticleRepository extends Repository<Article, Long> {
// Pageable включає і параметри сторінки (page/size), і сортування (Sort)
Page<Article> findByStatus(ArticleStatus status, Pageable pageable);
}
Тепер пишемо тест на першу сторінку «публічної стрічки». Тут сортування задаємо явно, бо «порядок за замовчуванням» — це як «пароль за замовчуванням»: начебто зручно, але потім соромно.
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
// Запитуємо першу сторінку (page=0) розміром 2, із сортуванням за полем сутності publishedAt
Page<Article> page = repository.findByStatus(
ArticleStatus.PUBLISHED,
PageRequest.of(0, 2, Sort.by("publishedAt").descending())
);
// totalElements — це кількість усіх елементів за фільтром, а не лише на цій сторінці
assertThat(page.getTotalElements()).isEqualTo(5);
// containsExactly фіксує і склад, і порядок — для пагінації порядок критичний
assertThat(page.getContent()).extracting(Article::getSlug)
.containsExactly("jpa-tips", "spring-boot-4");
Тут ми перевірили дві ключові речі. По-перше, totalElements справді рахує всі опубліковані статті, а не лише ті, що потрапили на сторінку. По-друге, content має саме той порядок, який ми очікуємо. І так, containsExactly — це не примха: він фіксує порядок. Якщо порядок важливий, тест зобов’язаний бути суворим до порядку.
Якщо хочеться додати ще трохи метаданих, але не перетворити тест на енциклопедію Page, можна перевірити номер сторінки і загальну кількість сторінок:
// Номер сторінки — 0-based, тож «перша сторінка» = 0
assertThat(page.getNumber()).isEqualTo(0);
// При total=5 і size=2 отримуємо 3 сторінки: 2 + 2 + 1
assertThat(page.getTotalPages()).isEqualTo(3);
// Прапорець навігації: на першій сторінці при total > size наступна сторінка обов’язково існує
assertThat(page.hasNext()).isTrue();
Ці три перевірки зазвичай дають чудову впевненість: ми точно на першій сторінці, сторінок усього три, і є куди гортати далі.
6. Однакове поле сортування і вторинне сортування
Тепер розберімо випадок, який часто ламає видачу «без видимих причин». Уявіть, що дві статті опубліковані в один і той самий момент. За сортуванням лише за publishedAt desc база має право повернути їх у будь-якому порядку. В одному запуску — так, в іншому — навпаки. І це виглядає як flaky-тест, хоча насправді flaky тут не тест, а контракт сортування.
Давайте створимо дві статті з однаковим publishedAt і покажемо, як вторинне сортування робить результат стабільним. Найпростіший стабільний tie-breaker — id, але щоб він спрацював, ми зобов’язані включити його в Sort.
Instant sameTime = Instant.parse("2026-03-20T10:00:00Z");
// Обидва об’єкти мають однаковий publishedAt — без вторинного сортування порядок буде невизначеним
entityManager.persist(published("first", sameTime));
entityManager.persist(published("second", sameTime));
entityManager.flush();
entityManager.clear();
Далі будуємо PageRequest із подвійним сортуванням і читаємо сторінку:
// Додаємо tie-breaker: publishedAt DESC, потім id DESC
Page<Article> page = repository.findByStatus(
ArticleStatus.PUBLISHED,
PageRequest.of(0, 10,
Sort.by("publishedAt").descending()
.and(Sort.by("id").descending()))
);
// Перевіряємо саме порядок, який має бути стабільним за однакового publishedAt
assertThat(page.getContent()).extracting(Article::getSlug)
.containsExactly("second", "first");
Чому очікуємо "second" раніше "first"? Тому що за однакового publishedAt вирішує id desc, а в другої persist-операції зазвичай буде більший id. Ми не прив’язуємося до конкретних чисел id, ми прив’язуємося до відносного порядку, який і робить систему передбачуваною.
Цей приклад, до речі, дуже життєвий: коли система публікує пачку статей майже одночасно, однакові timestamp’и — це не фантазія, а реальність.
7. Достатнє покриття пагінації в data-layer
Пагінацію можна тестувати без кінця, особливо якщо вас колись образив LIMIT/OFFSET. Але в реальному проєкті нам потрібен хороший ROI: тести мають давати впевненість, а не колекцію перевірок заради самих перевірок. Практична стратегія така: ви обираєте 1–2 ключові репозиторні методи, через які живе публічне читання, і перевіряєте для них кілька речей — фільтр, порядок, розбиття на сторінки та мінімальні метадані.
Якщо метод критично важливий для public API, як-от стрічка опублікованих статей, зазвичай достатньо мати один тест на першу сторінку з явним сортуванням і перевіркою totalElements/totalPages, і другий тест на наступну сторінку, щоб переконатися, що розбиття не зсунулося. Приклад другого тесту можна зробити дуже компактним:
// Запитуємо другу сторінку (page=1) при size=2 — це «елементи 3–4» в упорядкованому списку
Page<Article> page1 = repository.findByStatus(
ArticleStatus.PUBLISHED,
PageRequest.of(1, 2, Sort.by("publishedAt").descending())
);
// Перевіряємо, що ми справді на сторінці з індексом 1
assertThat(page1.getNumber()).isEqualTo(1);
// Вміст другої сторінки — строго очікувані елементи в очікуваному порядку
assertThat(page1.getContent()).extracting(Article::getSlug)
.containsExactly("testing-slices", "dto-contracts");
Зверніть увагу: ми не повторюємо totalElements в усіх тестах підряд. Якщо його вже перевірили в тесті першої сторінки і дані в тестовому класі однакові, другий тест може зосереджуватися на іншому ризику: «сторінки не переплуталися».
І ще один важливий момент, який різко покращує читабельність suite: якщо ви бачите, що один тест одночасно перевіряє складний фільтр, складне сортування, вторинне сортування, частину підрахунку і навігацію, то найчастіше цей тест треба не ускладнювати, а розділити. Пагінація — не привід будувати Big Bang Test. Це просто ще одна грань поведінки запиту.
8. Типові помилки в @DataJpaTest
Помилки в пагінації часто виглядають не як «червоний тест», а як «дивний баг у користувача». Тому корисно заздалегідь знати, де найчастіше стелять килимок, щоб ви на нього не наступили. Тут не буде «страшних антипатернів рівня космос», лише дуже земні промахи, які робить майже кожен, коли вперше тестує Page<T>.
Помилка №1: не задавати сортування взагалі й очікувати стабільного порядку.
Якщо ви викликаєте репозиторій з PageRequest.of(0, 20) без Sort, ви отримуєте невизначений порядок. На маленькій базі й у embedded-середовищі він може здаватися стабільним, але це ілюзія. У тестах і в коді сортування має бути явним: або в PageRequest, або в самому запиті, якщо це частина контракту.
Помилка №2: сортувати лише за одним полем, у якого бувають однакові значення.
Сортування за publishedAt без вторинного ключа — класичне джерело плаваючих результатів. Щойно два об’єкти ділять один timestamp, порядок між ними стає довільним. Додавайте вторинне сортування, найчастіше за id, і тестуйте саме детермінований порядок.
Помилка №3: готувати даних рівно на одну сторінку й думати, що ви протестували пагінацію.
Якщо size=2 і ви створили рівно два записи, ви протестували лише те, що репозиторій уміє повернути список із двох елементів, але не те, що він уміє розбивати їх на сторінки. Для чесного тесту даних має бути більше, ніж уміщається в одну сторінку, інакше межа розбиття просто не існує.
Помилка №4: перевіряти тільки розмір сторінки (hasSize(2)) і нічого більше.
Перевірка розміру може бути корисною як додаткова, але сама по собі вона майже нічого не доводить. Тест має фіксувати хоча б slug/id — тобто конкретні елементи, — і якщо порядок важливий, фіксувати його через containsExactly, а не через «містить».
Помилка №5: забувати flush() і clear() перед читанням.
Без flush/clear ви ризикуєте перевіряти не базу, а managed-об’єкти в persistence context. У пагінації це особливо неприємно, бо «воно начебто працює», а потім несподівано ламається, коли дані стають складнішими. flush/clear — це ваш спосіб сказати: «Я хочу побачити реальність бази».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ