JavaRush /Курсы /Spring Data JPA /Граница repository tests

Граница repository tests

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

1. Тихие поломки persistence-layer

Если вы когда-нибудь думали: “Ну репозиторий же интерфейс, он компилируется — значит всё нормально”, то вы не одиноки. Компиляция Java действительно проверяет много полезного: типы, сигнатуры, доступность классов. Но persistence-layer живёт на стыке трёх миров, и компилятор видит только один из них.

Первый мир — это ваш Java‑код: сущности, аннотации, репозитории, запросы в @Query. Второй мир — реальная база данных: схема таблиц, ограничения, индексы, последовательности, данные. Третий мир — “машина по превращению всего этого в SQL”: Hibernate, Spring Data JPA, их правила парсинга derived queries, генерации join’ов, выполнения JPQL. И вот этот треугольник может быть “непротиворечивым на бумаге”, но ломаться при реальном выполнении.

Очень характерная поломка: вы добавили поле в entity, переименовали колонку в миграции, поправили @Column(name = "..."), а потом внезапно один из запросов возвращает “не то” или падает на выполнении. Причём падение может быть не на старте приложения, а только на конкретном кейсе: “открыть страницу каталога с сортировкой по цене”, “показать заказы клиента”, “вытащить low-stock report”. Именно такие вещи особенно неприятны: они не кричат “я сломан” при запуске, они ждут, когда вы нажмёте “не ту кнопку”.

Repository test как раз нужен, чтобы выдернуть этот треугольник в контролируемую среду и убедиться, что “контракт данных” выполняется: сущность сохраняется, связь переживает запись/чтение, запрос реально фильтрует, пагинация стабильна. Это не про “покрыть код процентами”, а про то, чтобы data-layer перестал быть лотереей.

2. Repository test: состав среза

Repository test — это тест, который проверяет поведение слоя доступа к данным в присутствии настоящей JPA‑инфраструктуры: EntityManager, Hibernate, транзакций тестового раннера и реальной базы (или максимально похожей на неё тестовой базы). То есть это не “юнит‑тест репозитория” в смысле моков. Репозиторий как раз и ценен тем, что он делает работу внутри фреймворка, а не в чистой Java-логике.

Чтобы не путаться, полезно держать в голове простую карту типов тестов. Не как “единственно правильную классификацию”, а как здравый смысл — что мы проверяем и сколько это стоит по времени запуска.

Тип проверки Что тестируем на самом деле Что не тестируем Скорость
Юнит‑тест (pure Java) Логику методов без БД, без Spring mapping, SQL, транзакции очень быстро
Repository test (JPA slice) mapping + запросы + поведение репозитория с БД web, JSON, контроллеры, “весь бизнес‑процесс” быстро/средне
Service integration test use case на уровне сервиса + транзакционные границы + несколько репозиториев web-контракты средне
End‑to‑end (полный запуск) весь путь “HTTP → сервис → БД” почти ничего медленно

В нашей сегодняшней лекции фокус именно на второй строке: repository tests. Это тот уровень, где мы ловим проблемы “аннотация поставлена, но работает не так”, “запрос написан, но фильтрует неверно”, “связь есть, но FK в базе не туда смотрит”.

Нагляднее всего это видно на схеме. Repository test — это как фонарик, который светит в конкретный угол системы, а не прожектор, который пытается осветить весь мир сразу:

flowchart TD
    A[Repository test] --> B[Spring Data JPA repository]
    B --> C["Hibernate / EntityManager"]
    C --> D[(Database)]

    A -. НЕ тестируем .-> E["Controller / JSON"]
    A -. НЕ тестируем .-> F[Security]
    A -. НЕ тестируем .-> G[Внешние интеграции]
    A -. Часто НЕ тестируем .-> H[Сложная orchestration в сервисах]

Граница repository test обычно проходит по принципу: “мы проверяем всё, что связано с data-contract репозитория”. То есть вопрос теста звучит примерно так: “Если в базе есть такие данные, метод репозитория должен вернуть вот это, в таком порядке, с такой пагинацией”. Если ваш тест начинает превращаться в историю “оформи заказ, уменьши остатки, пересчитай сумму, обработай optimistic locking, затем отправь email” — вы уже убежали в тестирование сервисного use case, и это другой уровень (он будет завтра, на Дне 28).

Важный момент: repository test — это не “маленький integration test ради галочки”, а способ сделать слой данных предсказуемым. Он экономит время не потому, что его “приятно писать”, а потому что он быстрее всего отвечает на вопрос: “у нас сломался слой данных или нет?”.

3. Когда окупаются repository tests

«Метод существует, но запрос не работает так, как вы думаете»

Очень легко написать derived query, которая выглядит разумно, компилируется, а потом внезапно делает не то. Например, вы ожидаете, что findByStatus(...) вернёт только активные товары, но из‑за изменения enum’а или бизнес‑логики статусов вы начали сохранять "ARCHIVED" там, где раньше было "INACTIVE", и логика выборки стала расходиться с ожиданием. Сам репозиторий при этом честно возвращает то, что вы попросили — просто вы не замечаете, что контракт поменялся.

Repository test фиксирует этот контракт явно: в фикстуре есть “должен попасть” и “не должен попасть”, и проверка ломается сразу, когда реальность уехала. Это особенно полезно в проекте типа нашего mini‑shop, где read‑сценарии — половина ценности приложения: каталог, поиск, отчёты, админские фильтры.

«Связь в Java есть, а в БД — нет (или наоборот)»

С relationship mapping новичковая боль всегда одна и та же: вы в памяти построили красивый объектный граф (категория содержит товары, заказ содержит позиции), а после save(...) оказалось, что в базе FK не проставился, коллекция потом читается пустой или вообще вылезает constraint violation. И самое неприятное: пока вы не сделали flush и повторное чтение, вы можете даже не заметить проблемы, потому что persistence context “успокаивает” вас наличием объектов в памяти.

Repository test как раз проверяет устойчивость модели: “сохранили → прочитали заново → получили то, что ожидали”. В день, когда вы добавляете cascade, orphanRemoval или helper‑методы для двусторонних связей, такие тесты окупаются практически мгновенно: они превращают “кажется, работает” в “работает гарантированно”.

«Пагинация работает, но… нестабильно»

Пагинация — это классический источник “фантомных” багов. Вы сделали PageRequest.of(0, 20) и видите 20 элементов. Затем кто-то добавил ещё пару строк в таблицу, или поменялся план запроса, или база решила вернуть строки в другом физическом порядке (а вы не задали ORDER BY). И внезапно “вторая страница” стала содержать элементы, которые уже были на первой, или наоборот — некоторые элементы пропали.

Это не “каприз базы”, это просто честная реальность SQL: без ORDER BY порядок не гарантирован. Repository test тут работает как сигнализация: если ваш метод репозитория обещает стабильную пагинацию, тест должен закреплять явную сортировку. Если сортировка случайно пропала — тест упадёт сразу, а не через неделю на проде, когда кто-то впервые откроет 17‑ю страницу списка.

4. Граница repository test

Если писать тесты без дисциплины, можно легко получить противоположный эффект: тесты будут падать “на погоду”, а вы начнёте относиться к ним как к шуму. Поэтому важно не только “вообще тестировать”, но и понимать, как держать границу.

Хороший repository test почти всегда отвечает на один конкретный вопрос. Это может быть вопрос про сохранение сущности, про поведение связи, про фильтр запроса, про сортировку или пагинацию. Но это один вопрос. Как только вы пытаетесь одним тестом доказать всё сразу — вы не делаете тест “сильнее”, вы делаете его “непонятнее”: при падении будет сложно понять, что именно сломалось, а при поддержке — тест будет требовать правок по любому поводу.

Ещё одна полезная граница: repository test не должен пытаться подменить собой тестирование сервиса. Репозиторий — это слой доступа к данным. Как только вы начинаете внутри теста строить “мини‑placeOrder()” со всеми инвариантами заказа, пересчётом суммы, обработкой ошибок и возвратом остатков — это уже тест уровня OrderService. Да, он тоже нужен, но он нужен в другом месте и по другой причине. Repository test должен оставаться узким: он проверяет, что данные можно сохранить/прочитать, и что конкретные методы репозитория действительно делают то, что заявлено их именем или @Query.

И последнее: repository test не про “проверить, что Spring работает”. Spring, как ни странно, обычно работает. Нам важнее проверить, что мы правильно описали mapping и правильно сформулировали запрос. В этом смысле repository tests — это не экзамен для Spring Data, это экзамен для нашего собственного кода и нашей собственной схемы.

5. Примеры data‑контрактов

Ниже — несколько маленьких фрагментов, которые показывают, как репозиторий выражает контракт, и как тест начинает этот контракт “прибивать гвоздями”. Детали настройки slice‑контекста (@DataJpaTest, test‑profile, видимость SQL, подготовка данных) мы разберём в следующих лекциях дня — здесь нам важна именно идея.

Пример 1 — репозиторий как контракт

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

// Репозиторий — это контракт доступа к данным для конкретной сущности.
// Здесь мы описываем, какие операции чтения/записи считаем "официально поддерживаемыми" в приложении.
public interface CategoryRepository extends JpaRepository<Category, Long> {

    // Derived query: бизнес-ключ code должен позволять найти категорию.
    // Optional — потому что категория может отсутствовать (и это не исключение).
    Optional<Category> findByCode(String code);
}

Даже в таком простом интерфейсе уже есть смысл: “Категория обязана находиться по code”. Компилятор проверит, что метод существует. Spring Data проверит, что оно похоже на derived query. Но только тест реально докажет, что “в нашей схеме, с нашим mapping, с нашими данными” это работает так, как мы ожидаем.

Пример 2 — минимальный тест, который проверяет DB-backed поведение

import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.assertj.core.api.Assertions.assertThat;

// @DataJpaTest поднимает "срез" JPA: EntityManager, репозитории, транзакции и тестовую БД.
// Обычно каждый тест выполняется в транзакции и откатывается после завершения.
@DataJpaTest
class CategoryRepositoryTest {

    // Внедряем настоящий Spring Data репозиторий (без моков).
    @Autowired
    CategoryRepository categoryRepository;

    @Test
    void savesAndReadsCategory() {
        // Arrange: сохраняем сущность в БД (проверяем, что persist вообще работает в текущей схеме/миграциях).
        Category saved = categoryRepository.save(new Category("tea", "Tea"));

        // Assert: читаем по id и проверяем, что запись действительно существует.
        assertThat(categoryRepository.findById(saved.getId())).isPresent();
    }
}

Это выглядит почти смешно простым — и это хорошо. Такой тест полезен как “дымовой датчик”: он подтверждает, что базовый persist/find цикл вообще работает в данном окружении. Когда вы добавляете auditing, меняете генерацию id, правите миграции или меняете типы колонок — именно такие тесты первыми начинают говорить: “кажется, мы что-то отломали”.

Пример 3 — тест как проверка смысла фильтра, а не факта “что-то вернулось”

import java.util.List;
import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;

import static org.assertj.core.api.Assertions.assertThat;

class ProductRepositoryTest {

    // В repository test мы вызываем реальные методы репозитория, чтобы запрос действительно выполнялся в БД.
    @Autowired
    ProductRepository productRepository;

    @Test
    void findsOnlyActiveProducts() {
        // Arrange: кладём в БД "должен попасть" и "не должен попасть".
        productRepository.save(ProductTestFactory.active("SKU-1"));   // должен попасть в выборку
        productRepository.save(ProductTestFactory.archived("SKU-2")); // не должен попасть в выборку

        // Act: вызываем метод, который выражает контракт фильтрации по статусу.
        List<Product> result = productRepository.findByStatus(ProductStatus.ACTIVE);

        // Assert: важна не "непустота", а то, что лишнее реально отфильтровалось.
        assertThat(result).hasSize(1);
    }
}

Здесь главное — не число 1, а структура мысли: в данных есть запись, которая обязана попасть, и запись, которая обязана не попасть. Именно это превращает тест в проверку контракта, а не в “помолимся, чтобы список был непустой”.

Не пугайтесь ProductTestFactory: это просто удобный способ не писать в тесте 15 строк конструирования сущности. Как аккуратно готовить данные и где проходит граница “удобно” vs “магия” — тоже разберём дальше сегодня.

6. Типичные ошибки при тестировании repository-layer

Ошибка №1: пытаться одним тестом доказать работу всего приложения.
Это очень естественное желание: раз уж подняли контекст, давайте “заодно” проверим и сервис, и репозиторий, и миграции, и заказы, и отмену заказа. В итоге тест превращается в роман на 300 строк: падает — непонятно где, проходит — непонятно что именно он доказал. Repository test должен оставаться коротким и отвечать на один вопрос о данных.

Ошибка №2: тестировать “только not null”, а не поведение.
Проверки вида “объект сохранился и не null” мало что дают, если вы не проверяете смысл. Куда ценнее проверить, что фильтр реально отсекает “лишнее”, что связь реально переживает запись/чтение, что пагинация стабильна при явной сортировке. Тест без смысла обычно зелёный… пока не становится бесполезным.

Ошибка №3: смешивать mapping, query и пагинацию в одном тесте.
Mapping тесты должны ловить проблемы mapping. Query тесты — проблемы запросов. Pagination тесты — проблемы порядка и count‑семантики. Если смешать всё в одном сценарии, то при падении вы получите квест “угадай, что сломалось”. Нам нужно обратное: чтобы падение сразу указывало на причину.

Ошибка №4: считать, что если репозиторий “создался”, то метод точно работает.
Репозиторий может подняться, приложение может стартовать, но конкретный запрос может падать при выполнении или возвращать неожиданный результат на реальных данных. Repository test ценен тем, что он заставляет метод репозитория реально выполниться. Это как проверка двери: можно смотреть на дверь и говорить “ну она же существует”, а можно попробовать её открыть.

Ошибка №5: превращать тесты в хрупкую копию реализации.
Если тест проверяет каждое поле каждой сущности, любые изменения модели будут ломать тесты “по мелочи”, и вы начнёте их чинить машинально, не глядя. В repository test обычно стоит проверять только то, что составляет контракт текущего сценария: ключевые поля фильтра, ожидаемый размер результата, важный порядок. Остальное — лишний шум, который не добавляет уверенности.

1
Задача
Spring Data JPA, 27 уровень, 0 лекция
Недоступна
Отдельный smoke-test для CategoryRepository
Отдельный smoke-test для CategoryRepository
1
Задача
Spring Data JPA, 27 уровень, 0 лекция
Недоступна
Контракт существования товара после сохранения
Контракт существования товара после сохранения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ