JavaRush /Курси /Spring Data JPA /Тести: сортування, Page/Slice, Specification

Тести: сортування, Page/Slice, Specification

Spring Data JPA
Рівень 27 , Лекція 4
Відкрита

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, тест буде зеленим навіть за зламаної умови. Це той самий «занадто зелений» тест, який не ловить регресії. Фікстура має містити хоча б одного «поганого кандидата», якого обов’язково слід відфільтрувати.

1
Опитування
Тести репозиторіїв, рівень 27, лекція 4
Недоступний
Тести репозиторіїв
Перевірка JPA та SQL
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ