JavaRush /Курсы /Spring Data JPA /Тесты: сортировка, Page/Slice, Spec

Тесты: сортировка, Page/Slice, Spec

Spring Data JPA
27 уровень , 4 лекция
Открыта

1. Введение

Если вы когда-нибудь видели тест, который падает «примерно раз в 20 запусков» — есть хорошие шансы, что там прячется пагинация без сортировки или сортировка, которая случайно опирается на порядок строк «как вернулось». Реляционная БД не обязана возвращать строки в стабильном порядке, если вы явно не попросили ORDER BY, и это нормальная, взрослая позиция базы: «я вам не Excel».

В JPA-тестах это проявляется ещё веселее: вы видите список, он «как будто отсортирован», потому что у вас маленький объём данных и всё красиво легло на диск, а потом появляется другой план выполнения, другой индекс, другой запуск — и порядок поплыл. Добавьте сюда Page, где появляется дополнительный count-запрос, и вы получите набор тестов, которые кажутся простыми… пока не начинают жить собственной жизнью.

Чтобы не делать из тестов гадание на кофейной гуще, мы будем относиться к сортировке и пагинации как к контракту метода репозитория, а не как к «параметрам, которые можно не проверять».

2. Тест сортировки: контракт порядка

Сортировка — это не украшение. В репозитории она обычно выражает конкретное бизнес-ожидание: «каталог должен быть отсортирован по имени», «в админке сначала новые», «в отчёте сначала самые проблемные». Если в тесте вы не фиксируете сортировку, вы часто тестируете не контракт, а случайность. И самое грустное: тест может проходить годами, пока однажды не начнёт падать после апдейта БД или добавления индекса.

В проекте shop-data-jpa сортировка чаще всего всплывает в каталоге (Product) и в админских поисках (там сортировка обычно идёт вместе с фильтрами). Поэтому начнём с простого: будем тестировать сортировку по имени или по id, и будем делать это так, чтобы порядок было невозможно «угадать», но легко «проверить».

Дальше я буду использовать один shorthand для fixture: saveActive(...) и saveArchived(...). Это локальные helper’ы поверх уже знакомого явного setup: они создают и сразу сохраняют Product, а в перегрузке с Category ещё и привязывают категорию. Нам здесь важны порядок, страница и фильтр, а не повтор одного и того же boilerplate.

Сначала покажем типичный 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");

        // Делаем запрос честно DB-backed.
        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.content"]
    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");

    // Фиксируем DB-backed поведение.
    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? Ответ зависит от того, является ли это контрактом вашего use case. Если ваш метод используется для 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 + пагинация (например, админский поиск). Тестируем так же: сортировка обязательна, и мы проверяем только то, что является контрактом.

Здесь уже нужен набор хотя бы из нескольких подходящих записей; чтобы не дублировать boilerplate, ниже показан только сам вызов с PageRequest, а fixture считается подготовленным тем же shorthand-способом.

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() там, где важно DB-backed поведение.
Здесь действует та же базовая дисциплина repository tests: sorting, paging и Specification должны проверять реальный запрос, а не случайное состояние текущего persistence context.

Ошибка №5: делать фикстуру, где все записи одинаково подходят под фильтр.
Если вы проверяете Specification «status=ACTIVE and category=tea», а в фикстуре у вас только active и только tea, тест будет зелёным даже при сломанном условии. Это тот самый «слишком зелёный» тест, который не ловит регрессии. Фикстура должна содержать хотя бы одного «плохого кандидата», который обязан быть отфильтрован.

1
Задача
Spring Data JPA, 27 уровень, 4 лекция
Недоступна
Page-тест для каталога с явной сортировкой
Page-тест для каталога с явной сортировкой
1
Задача
Spring Data JPA, 27 уровень, 4 лекция
Недоступна
Specification-тест с двумя фильтрами
Specification-тест с двумя фильтрами
1
Опрос
Тесты репозиториев, 27 уровень, 4 лекция
Недоступен
Тесты репозиториев
Проверка JPA и SQL
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ