1. Вступ
Якщо ви колись бачили тест, який падає «приблизно раз на 20 запусків», — є великі шанси, що там ховається пагінація без сортування або сортування, яке випадково спирається на порядок рядків «як повернулося». Реляційна база даних не зобов’язана повертати рядки в стабільному порядку, якщо ви явно не попросили ORDER BY, і це нормальна, доросла позиція бази: «я вам не Excel».
У JPA-тестах це проявляється ще очевидніше: ви бачите список, він «ніби відсортований», тому що у вас невеликий обсяг даних і все вдало лягло на диск, а потім з’являється інший план запиту, інший індекс, інший запуск — і порядок пливе. Додайте сюди Page, де з’являється додатковий count-запит, і ви отримаєте набір тестів, які здаються простими… доки не починають жити власним життям.
Щоб не перетворювати тести на ворожіння на кавовій гущі, ми будемо ставитися до сортування і пагінації як до контракту методу репозиторію, а не як до «параметрів, які можна не перевіряти».
2. Тест сортування: контракт порядку
Сортування — це не прикраса. У репозиторії воно зазвичай виражає конкретне бізнес-очікування: «каталог має бути відсортований за ім’ям», «в адмінці спочатку нові», «у звіті спочатку найпроблемніші». Якщо в тесті ви не фіксуєте сортування, ви часто перевіряєте не контракт, а випадковість. І найсумніше: тест може проходити роками, доки одного дня не почне падати після оновлення БД або додавання індексу.
У проєкті shop-data-jpa сортування найчастіше спливає в каталозі (Product) і в адмінських пошуках (там сортування зазвичай іде разом із фільтрами). Тому почнемо з простого: тестуватимемо сортування за ім’ям або за id і робитимемо це так, щоб порядок було неможливо «вгадати», але легко «перевірити».
Далі я використаю одне коротке позначення для фікстури: saveActive(...) і saveArchived(...). Це локальні допоміжні методи поверх уже знайомої явної підготовки: вони створюють і відразу зберігають Product, а в перевантаженні з Category ще й прив’язують категорію. Нам тут важливі порядок, сторінка і фільтр, а не повторення одного й того ж шаблонного коду.
Спочатку покажемо типовий repository-метод, який приймає Sort:
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
interface ProductRepository extends JpaRepository<Product, Long> {
// Контракт: метод повертає товари із зазначеним статусом, а порядок задає параметр sort.
// Без sort порядок залежатиме від БД і плану виконання запиту.
List<Product> findByStatus(ProductStatus status, Sort sort);
}
Тепер тест. Важливо: ми створимо дані так, щоб без сортування порядок був неочевидним, і явно вимагатимемо сортування за name.
import jakarta.persistence.EntityManager;
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.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ProductSortingTest {
@Autowired ProductRepository productRepository;
@Autowired EntityManager entityManager;
@Test
void sortsByNameAscending() {
// Arrange: зберігаємо дані в "незручному" порядку, щоб сортування неможливо було вгадати.
saveActive("SKU-3", "Citrus Tea");
saveActive("SKU-1", "Apple Tea");
saveActive("SKU-2", "Berry Tea");
// Працюємо безпосередньо з БД.
entityManager.flush();
entityManager.clear();
// Act: явно задаємо сортування за entity-властивістю `name`.
var sorted = productRepository.findByStatus(
ProductStatus.ACTIVE,
Sort.by("name").ascending()
);
// Assert: порядок — частина контракту методу.
assertThat(sorted)
.extracting(Product::getName)
.containsExactly("Apple Tea", "Berry Tea", "Citrus Tea");
}
}
flush() і clear() тут — проста страховка, щоб сортування перевірялося на реальному запиті, а не на поточному persistence context. Загальну механіку ми вже розбирали; у цьому блоці нам важливий саме контракт порядку.
Є ще один важливий нюанс: у сортуванні ви звертаєтеся до властивостей entity, а не до колонок таблиці. Тобто name — це ім’я поля Product.name, а не product_name у БД. Це зручно, але тест якраз допомагає впіймати помилки під час рефакторингу: перейменували поле — сортування зламалося, і це правильно, що тест упав.
3. Тест Page: вміст і count
Page — штука практична: ви отримуєте вміст поточної сторінки та метадані (totalElements, totalPages), які зручні для UI. Але в цієї зручності є ціна: майже завжди Page означає щонайменше два запити — один за даними поточної сторінки, другий за count. І в тестах це відбивається так само: ви маєте розуміти, що саме перевіряєте — вміст сторінки, метадані або обидва пункти, і перевіряти лише те, що справді є контрактом вашого методу.
Щоб зафіксувати модель, корисно тримати в голові просту схему:
flowchart TD
A["Запит Pageable"] --> B["SELECT ... ORDER BY ... LIMIT/OFFSET"]
A --> C["SELECT COUNT(*) ..."]
B --> D["Вміст Page"]
C --> E["Page.totalElements / totalPages"]
У проєкті ми можемо мати метод, який явно декларує countQuery (особливо якщо запит складний). Приклад — спрощений, але показовий:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
interface ProductRepository extends JpaRepository<Product, Long> {
// Контракт: data-запит і count-запит мають бути семантично узгоджені,
// інакше UI може отримати "правильний вміст" і "неправильний total".
@Query(
value = """
select p from Product p
where p.status = :status
""",
countQuery = """
select count(p) from Product p
where p.status = :status
"""
)
Page<Product> findPageByStatus(ProductStatus status, Pageable pageable);
}
У derived query countQuery зазвичай генерується автоматично, і це нормально. Тут ми показуємо явну форму, тому що в тестах, а також у реальному коді, ви іноді хочете точно контролювати count-семантику.
Тепер тест на пагінацію. Ми створимо кілька елементів, запросимо другу сторінку (сторінки в Spring Data починаються з нуля), задамо сортування і перевіримо базовий контракт.
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void returnsSecondPageInStableOrder() {
// Arrange: створюємо 5 елементів.
saveActive("SKU-1", "A");
saveActive("SKU-2", "B");
saveActive("SKU-3", "C");
saveActive("SKU-4", "D");
saveActive("SKU-5", "E");
// Фіксуємо поведінку, підтверджену БД.
entityManager.flush();
entityManager.clear();
// Act: запитуємо другу сторінку (index=1) по 2 елементи з явним сортуванням.
var pr = PageRequest.of(1, 2, Sort.by("name").ascending());
var page = productRepository.findPageByStatus(ProductStatus.ACTIVE, pr);
// Assert: перевіряємо те, що вважаємо контрактом, — номер сторінки і вміст.
assertThat(page.getNumber()).isEqualTo(1);
assertThat(page.getContent())
.extracting(Product::getName)
.containsExactly("C", "D");
}
Тут ми перевірили дві важливі властивості: номер сторінки і вміст. А ось питання: чи треба перевіряти totalElements? Відповідь залежить від того, чи є це контрактом вашого сценарію використання. Якщо ваш метод використовується для UI, який показує «сторінку 2 з 10», тоді так — count важливий.
Перевірка count-контракту може виглядати так, і це окреме твердження, щоб не перетворювати тест на комбайн:
import static org.assertj.core.api.Assertions.assertThat;
@Test
void pageContainsTotalCountMetadata() {
// Arrange: створюємо 3 активних товари.
saveActive("SKU-1", "A");
saveActive("SKU-2", "B");
saveActive("SKU-3", "C");
entityManager.flush();
entityManager.clear();
// Act: перша сторінка (index=0), розмір 2.
var pr = PageRequest.of(0, 2, Sort.by("name").ascending());
var page = productRepository.findPageByStatus(ProductStatus.ACTIVE, pr);
// Assert: totalElements/totalPages — частина контракту лише тоді, коли вони справді використовуються.
assertThat(page.getTotalElements()).isEqualTo(3);
assertThat(page.getTotalPages()).isEqualTo(2);
}
Чому я розділяю перевірки? Тому що тести на Page дуже легко перетворюються на крихкий «знімок реалізації». У підсумку ви перевіряєте все підряд, а потім змінюєте розмір сторінки, додаєте ще одну умову — і тест падає не тому, що зламався контракт, а тому, що ви перевіряли випадкові деталі.
Ще одна важлива думка: paged-тест без сортування майже завжди підозрілий. Навіть якщо «ніби й так працює», ви залишаєте порядок на милість БД. Краще явно вибрати сортування. Найбільш «технічно стабільний» варіант — Sort.by(id), тому що id унікальний і монотонно зростає. Але в бізнес-логіці частіше хочеться сортувати за name, createdAt, price — і це теж нормально, просто тестуйте це явно.
4. Тест Slice: hasNext() без count
Slice — це чесне визнання: «мені не потрібен загальний count, мені треба просто гортати далі». Він зазвичай швидший, тому що не потребує count-запиту. І через це Slice тестується іншим набором очікувань. У Slice ви не перевіряєте totalElements, тому що його там немає; ви перевіряєте розмір поточного набору і прапорець hasNext(). Це саме той випадок, коли «перевірити все» неможливо, і це добре: менше спокуси перевіряти зайве.
Якщо у нас є метод, який повертає slice, він може виглядати навіть як derived query:
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
interface ProductRepository extends JpaRepository<Product, Long> {
// Контракт: повертаємо "шматок" даних і ознаку, чи є продовження.
// За замовчуванням Slice не робить count-запит і не зобов'язаний знати totalElements.
Slice<Product> findByStatus(ProductStatus status, Pageable pageable);
}
Тепер тест. Ми створимо три товари, запросимо Slice розміром 2, переконаємося, що продовження є. Потім запросимо наступну «сторінку» і переконаємося, що продовження немає.
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void sliceReturnsHasNextFlag() {
// Arrange: 3 товари, щоб перший Slice був неповним за "загальною вибіркою".
saveActive("SKU-1", "A");
saveActive("SKU-2", "B");
saveActive("SKU-3", "C");
entityManager.flush();
entityManager.clear();
// Act: беремо перші 2 елементи з явним сортуванням (без нього порядок буде лотереєю).
var pr0 = PageRequest.of(0, 2, Sort.by("name").ascending());
var slice0 = productRepository.findByStatus(ProductStatus.ACTIVE, pr0);
// Assert: вміст і hasNext() — це і є контракт Slice.
assertThat(slice0.getContent())
.extracting(Product::getName)
.containsExactly("A", "B");
assertThat(slice0.hasNext()).isTrue();
}
У другому фрагменті набір даних той самий: A, B, C уже збережені тим самим способом. Тут змінюється лише номер сторінки та очікування щодо hasNext().
import static org.assertj.core.api.Assertions.assertThat;
@Test
void lastSliceHasNoNext() {
// Act: запитуємо наступний "шматок".
var pr1 = PageRequest.of(1, 2, Sort.by("name").ascending());
var slice1 = productRepository.findByStatus(ProductStatus.ACTIVE, pr1);
// Assert: залишився один елемент, і продовження більше немає.
assertThat(slice1.getContent())
.extracting(Product::getName)
.containsExactly("C");
assertThat(slice1.hasNext()).isFalse();
}
Тут я навмисно не перевіряю «номер сторінки» і «загальну кількість». Для Slice це не контракт. Контракт Slice — це «дай мені шматок даних і скажи, чи є ще». Якщо ви починаєте в Slice-тестах мріяти про totalElements, це вірна ознака, що ви насправді хочете Page.
І ще маленький, але важливий принцип: Slice теж потребує сортування, якщо вам потрібен стабільний порядок. Page без сортування — така сама лотерея, як і Slice без сортування. Просто лотерея буде швидшою.
5. Тест Specification: один фільтр за раз
Specification у нашому курсі — це прагматичний спосіб зібрати запит з опціональних умов, не створюючи 27 методів репозиторію на кожен чих. Але в тестах Specification легко перетворюється на «саморобну мову запитів, яку ніхто не розуміє», якщо ви не дисциплінуєте фікстури й очікування. Гарна новина: тестувати Specification насправді приємно, якщо пам’ятати просте правило: змінюємо одну умову за раз і тримаємо фікстуру мінімальною, але контрастною.
Для початку нагадаємо, що репозиторій має розширювати JpaSpecificationExecutor:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
Тепер невеликий фрагмент ProductSpecifications. Важливо: це код, який зазвичай лежить не в repository, а поруч, наприклад у catalog.query, щоб репозиторій не перетворювався на смітник із динамічної логіки.
import org.springframework.data.jpa.domain.Specification;
final class ProductSpecifications {
static Specification<Product> withStatus(ProductStatus status) {
return (root, query, cb) -> {
// Фільтр за полем entity `status`.
// Важливо: використовуємо імена властивостей, а не колонки таблиці.
return cb.equal(root.get("status"), status);
};
}
static Specification<Product> withCategoryCode(String code) {
return (root, query, cb) -> {
// Приклад фільтра за вкладеною властивістю через join path: category.code.
return cb.equal(root.get("category").get("code"), code);
};
}
}
Тепер тест на одну умову — статус:
import org.junit.jupiter.api.Test;
import org.springframework.data.jpa.domain.Specification;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void filtersByStatus() {
// Arrange: один активний, один архівний — щоб фільтр було справді видно.
saveActive("SKU-1", "A");
saveArchived("SKU-2", "B");
entityManager.flush();
entityManager.clear();
// Act: застосовуємо одну специфікацію.
Specification<Product> spec = ProductSpecifications.withStatus(ProductStatus.ACTIVE);
var result = productRepository.findAll(spec);
// Assert: перевіряємо склад результату.
assertThat(result).extracting(Product::getSku).containsExactly("SKU-1");
}
Тут важливий момент: containsExactly працює коректно лише тоді, коли порядок стабільний. У цьому тесті порядок фактично «випадковий», тому що ми не вказали Sort. Якщо ви хочете containsExactly, додавайте сортування. Якщо вам важливий лише склад, використовуйте contains/containsOnly або відсортуйте.
Більш «контрактна» версія:
import org.springframework.data.domain.Sort;
@Test
void filtersByStatusInStableOrder() {
// Act: додаємо Sort, щоб порядок був частиною перевірюваного контракту.
Specification<Product> spec = ProductSpecifications.withStatus(ProductStatus.ACTIVE);
var result = productRepository.findAll(spec, Sort.by("sku").ascending());
// Assert: тепер containsExactly справді має сенс.
assertThat(result).extracting(Product::getSku).containsExactly("SKU-1");
}
Тепер тест на комбінацію умов: статус + категорія. Для цього нам потрібна категорія. У фікстурі важливо мати дві категорії, інакше ви не доведете фільтрацію.
import org.springframework.data.jpa.domain.Specification;
@Test
void filtersByStatusAndCategoryCode() {
// Arrange: дві категорії, щоб одна умова точно відсікала частину даних.
Category tea = categoryRepository.save(new Category("tea", "Tea"));
Category coffee = categoryRepository.save(new Category("coffee", "Coffee"));
saveActive("SKU-1", "A", tea);
saveActive("SKU-2", "B", coffee);
entityManager.flush();
entityManager.clear();
// Act: комбінуємо специфікації через and(...).
Specification<Product> spec = ProductSpecifications.withStatus(ProductStatus.ACTIVE)
.and(ProductSpecifications.withCategoryCode("tea"));
var result = productRepository.findAll(spec);
// Assert: у результаті має залишитися лише чай.
assertThat(result).extracting(Product::getSku).containsExactly("SKU-1");
}
Тут ми зробили рівно те, що потрібно для переконливості: один запис має пройти фільтр, один — ні. І все. Не треба створювати «бойову базу даних на 50 продуктів», якщо ви перевіряєте два предикати. Пам’ятайте: добра фікстура — це не багато даних, а дані, які можна розрізнити.
Частий реальний сценарій: Specification + пагінація, наприклад для адмінського пошуку. Тестуємо так само: сортування обов’язкове, і ми перевіряємо лише те, що є контрактом.
Тут уже потрібен набір хоча б із кількох відповідних записів; щоб не дублювати шаблонний код, нижче показано лише сам виклик із PageRequest, а фікстуру вважаємо підготовленою тим самим коротким способом.
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@Test
void returnsFirstPageForSpecification() {
// Arrange: специфікація фіксує "які дані" шукаємо.
var spec = ProductSpecifications.withStatus(ProductStatus.ACTIVE);
// Act: пагінація + сортування фіксують "який шматок" і "в якому порядку" повертаємо.
var pr = PageRequest.of(0, 2, Sort.by("sku").ascending());
var page = productRepository.findAll(spec, pr);
// Assert: тут перевіряємо лише розмір вмісту сторінки.
assertThat(page.getContent().size()).isEqualTo(2);
}
Зверніть увагу: я не перевіряю totalElements, тому що в цьому прикладі не фіксував, скільки всього активних товарів. Якщо вам важлива count-поведінка, ви можете додати окремий тест саме на count, але знову ж таки — не перетворюйте один тест на енциклопедію.
І ще один важливий коментар: Specification-тести мають бути «про фільтр», а не «про fetch-план». Не намагайтеся сюди ж притягнути join fetch, @EntityGraph і боротьбу з N+1. Це окрема вісь. У JPA взагалі дуже легко зробити «один тест про все», а потім дивуватися, чому він ламається при найменшій зміні.
6. Типові помилки під час тестів: сортування, Page/Slice, Spec
Помилка №1: тестувати пагінацію без явного сортування.
На невеликій локальній базі порядок рядків часто «випадково стабільний», і створюється ілюзія, що все добре. Але без ORDER BY БД не гарантує порядок, а отже і Page, і Slice можуть повернути елементи в іншому порядку. У результаті тести стають нестабільними: сьогодні зелені, завтра червоні, а ви почуваєтеся детективом без доказів.
Помилка №2: писати для Slice перевірки як для Page.
Slice за змістом не зобов’язаний знати totalElements, і він не повинен заради вас робити count-запит. Якщо ви починаєте будувати assertions навколо total count, ви змушуєте Slice бути Page у голові, а це призводить до неправильних очікувань і до неправильного вибору типу в коді. Slice тестується через getContent() і hasNext().
Помилка №3: перевіряти в Page усі метадані «про всяк випадок».
У Page багато полів, і їх легко «просто перевірити». Але це перетворює тест на крихку фіксацію реалізації. Якщо ваш контракт — «друга сторінка містить елементи C і D», то перевіряйте це. totalPages і totalElements перевіряйте лише тоді, коли вони реально використовуються в сценарії і справді мають бути правильними.
Помилка №4: забувати про flush() і clear() там, де важлива поведінка, підтверджена БД.
Тут діє та сама базова дисципліна repository tests: sorting, paging і Specification мають перевіряти реальний запит, а не випадковий стан поточного persistence context.
Помилка №5: робити фікстуру, де всі записи однаково підходять під фільтр.
Якщо ви перевіряєте Specification «status=ACTIVE and category=tea», а у фікстурі у вас лише active і лише tea, тест буде зеленим навіть за зламаної умови. Це той самий «занадто зелений» тест, який не ловить регресії. Фікстура має містити хоча б одного «поганого кандидата», якого обов’язково слід відфільтрувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ