JavaRush /Курсы /Spring Test /Честные проверки БД после репозитория

Честные проверки БД после репозитория

Spring Test
15 уровень , 2 лекция
Открыта

1. save() не доказывает запись

Ловушка здесь уже знакомая: вы вызвали repository.save(article), он вернул saved, и мозг мгновенно превращает это в «значит, статья сохранена». Но save() живёт в той же JPA-механике, которую мы уже разобрали: изменения могут пока оставаться внутри persistence context, а не в таблице. Поэтому проверка saved.getTitle() почти всегда проверяет не persistence, а то, что объект в памяти сохранил свои поля.

Сейчас будет немного грустный, но полезный пример. Он похож на тест, который выглядит «вроде норм», проходит, а потом вы однажды ловите баг в проде и начинаете подозревать, что тесты у вас — просто декоративные зелёные лампочки.

import org.junit.jupiter.api.Test;

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

@Test
void save_returns_object_with_same_fields_but_it_proves_nothing() {
    // Сохраняем сущность: JPA может пока ничего не отправить в БД
    Article saved = repository.save(draftArticle("java-basics"));

    // Проверяем объект в памяти, а не состояние таблицы (по сути — "проверяем сеттеры")
    assertThat(saved.getSlug()).isEqualTo("java-basics"); // ну да, мы сами это задали
}

Проблема здесь не в AssertJ и не в том, что тест «неправильный по стилю». Он просто отвечает не на тот вопрос: умею ли я присвоить значение полю slug в Java-объекте? Ответ: «да». База данных в этом разговоре могла вообще не участвовать.

Даже проверка saved.getId() != null не всегда спасает. В одних стратегиях генерации идентификатора ID появляется ещё до фактической записи строки (например, через sequence), а в других — только на insert. То есть ID может появиться «рано», и вы снова получите ложную уверенность.

Самый рабочий способ вытащить тест из режима «самоуспокоение» — сделать видимой границу между памятью и базой: явный flush() и честное повторное чтение после clear().

2. Граница истины: flush() и clear()

flush() и clear() мы уже раскладывали подробно. Для repository-тестов это просто рабочая граница истины: после save/update/delete мы сначала синхронизируемся с БД, потом очищаем persistence context, и только после этого делаем финальные assertions.

Эту схему удобно держать в голове как мини-конвейер:

flowchart TD
    A["Arrange: подготовили объекты"] --> B["Act: вызвали repository-операцию"]
    B --> C["flush(): протолкнули изменения в БД"]
    C --> D["clear(): очистили persistence context"]
    D --> E["Assert: прочитали заново и проверили"]

В тестах ContentHub чаще всего это выглядит так: мы делаем save(), затем entityManager.flush() и entityManager.clear(), а дальше уже читаем заново и сравниваем.

Небольшой «инженерный лайфхак»: чтобы не повторять три строки в каждом тесте, можно сделать маленький helper, но такой, который не прячет смысл. Он должен называться честно и явно.

private void flushAndClear() {
    // Важно: "протолкнуть" накопленные изменения в БД именно сейчас
    entityManager.flush();
    // Важно: очистить persistence context, чтобы следующее чтение было именно из БД
    entityManager.clear();
}

Хелпер короткий, но не магический: он не сохраняет и не проверяет за нас, а только делает явной границу между действием и reread. Финальное чтение и финальные assertions лучше держать в теле теста.

3. Как проверять состояние БД

Когда вы приняли идею «после действия я проверяю БД, а не объект в памяти», появляется второй важный вопрос: а как именно проверять? Новичку хочется проверить всё сразу: и перечитать, и посчитать, и exists(), и ещё на всякий случай findAll(). Но data-тесты особенно чувствительны к «лишним проверкам», потому что они становятся длинными, шумными и плохо диагностируемыми.

Ниже — практичная таблица, которую можно держать как шпаргалку именно для @DataJpaTest:

Что вы хотите доказать Самый честный и простой способ Почему это хорошо
«Поле реально сохранилось (title/slug/status /authorUsername flushAndClear()findById()assertThat(...) Вы читаете заново и видите то, что реально лежит в таблице
«Запись реально появилась» flushAndClear()existsById(id) или count() Не всегда нужно перечитывать весь объект ради факта существования
«Запись реально исчезла (после delete)» delete...flushAndClear()findById().isEmpty() или existsById() == false Проверка простая, быстро объясняется и легко диагностируется
«В таблице ровно N строк» flushAndClear()count() Это прямой вопрос про количество строк, не надо создавать лишние чтения
«Repository вернул правильный объект по ключу» flushAndClear()findBy...() → assertions Вы проверяете и репозиторий, и то, что данные реально persisted

В рамках сегодняшнего дня мы не углубляемся в сложные выборки, сортировки и пагинации. Нам достаточно базовых «инструментов правды»: повторного чтения, exists() и count(). Они закрывают большую часть вопросов, которые возникают при тестировании сохранения, обновления и удаления.

4. Примеры repository-операций

Save и повторное чтение

Давайте теперь соберём всё в один понятный data-тест, который выглядит как нормальный сценарий: есть исходные данные, есть действие через repository, есть граница записи, и есть финальная проверка через повторное чтение. Мы будем использовать домен ContentHub: сущность Article, которая обязана иметь title, summary, body, category и уникальный slug.

Ниже draftArticle("...") — это сокращение для той же минимально валидной Article, где обязательные поля и Category уже собраны helper-ом. Так весь фокус остаётся на repository-поведении, а не на 40 строках setup-а.

Сначала покажу минимальный каркас тестового класса, чтобы не было ощущения «а где это всё живёт». Да, это чуть больше 10 строк, но один раз увидеть полезно.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest
class ArticleRepositoryDataJpaTest {

    // Репозиторий — то, что мы тестируем
    @Autowired ArticleRepository repository;
    // EntityManager — нужен, чтобы явно делать flush/clear (границу истины)
    @Autowired TestEntityManager entityManager;
}

Теперь сам тест. Он короткий и «пошаговый»: мы не прячем чтение и не прячем границу записи.

import org.junit.jupiter.api.Test;

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

@Test
void save_thenReload_shouldPersistSlugAndAuthor() {
    // Act: сохраняем сущность через repository
    Article saved = repository.save(draftArticle("java-basics"));

    // Граница истины: сначала реально записали в БД...
    entityManager.flush();
    // ...а затем заставили JPA перечитать всё заново, а не вернуть объект из кэша 1-го уровня
    entityManager.clear();

    // Assert: читаем заново и проверяем уже то, что лежит в таблице
    Article reloaded = repository.findById(saved.getId()).orElseThrow();
    assertThat(reloaded.getSlug()).isEqualTo("java-basics");
    assertThat(reloaded.getAuthorUsername()).isEqualTo("alice");
}

Здесь важно то, что reloaded — это новый объект, который пришлось загрузить после clear(). Это уже не «тот же самый Java-объект, который мы только что создали руками». И именно поэтому проверка начинает что-то доказывать.

Если вы хотите сделать тест ещё чуть честнее (и иногда это реально упрощает диагностику), можно добавить проверку «объекты разные по ссылке». Это не обязательная проверка, но обучающая.

// Дополнительная проверка: это не тот же самый экземпляр из persistence context
assertThat(reloaded).isNotSameAs(saved);

Она напоминает мозгу: «мы реально пошли в БД заново».

Update и повторное чтение

Обновления — самый частый источник ложной уверенности в JPA-тестах. Потому что вы меняете поле у управляемого объекта, затем читаете «как будто из базы», а на самом деле получаете тот же объект из persistence context. Визуально — всё обновилось. По факту — вы могли даже не проверить, что произошёл update на уровне БД (или что он произошёл тогда, когда вы думали).

Сделаем простой тест: сохранили статью, потом изменили title, протолкнули изменение в БД, очистили контекст и перечитали. Мы не пытаемся покрыть бизнес-правила статусов — мы проверяем только persistence-факт.

import org.junit.jupiter.api.Test;

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

@Test
void updateTitle_thenReload_shouldStoreNewTitle() {
    // Arrange/Act: сохраняем исходную статью
    Article article = repository.save(draftArticle("java-basics"));
    // Фиксируем факт сохранения в БД (чтобы тест читался как "записали")
    entityManager.flush();

    // Act: меняем поле у управляемой сущности
    article.setTitle("Java Basics (2nd edition)");
    // Проталкиваем update в БД
    entityManager.flush();
    // Очищаем контекст, чтобы следующая загрузка была честной
    entityManager.clear();

    // Assert: перечитали и проверили значение именно из БД
    Article reloaded = repository.findById(article.getId()).orElseThrow();
    assertThat(reloaded.getTitle()).isEqualTo("Java Basics (2nd edition)");
}

Обратите внимание на маленькую, но важную деталь: мы делаем flush() после сохранения, а потом ещё один flush() после изменения. Это помогает читать тест как историю: «записали → изменили → записали изменения → перечитали». У новичков часто есть соблазн сделать один flush() в конце. Иногда это работает, иногда даёт странные эффекты из-за того, что JPA может сама флашиться в неожиданный момент. А нам нужна предсказуемость и понятность.

Ещё один важный момент: мы не делаем проверку на объекте article после setTitle("Java Basics (2nd edition)"). Эта проверка была бы абсолютно бессмысленной, потому что сеттер в Java почти никогда не подводит. Если сеттер подвёл — у вас проблемы не с JPA, а с реальностью.

Delete и проверка отсутствия

Удаление — второй классический «обманщик». Вы вызываете repository.deleteById(id), потом проверяете что-то рядом и думаете «удалилось». Но если вы не сделали границу записи и не перечитали/не проверили существование, тест может остаться слишком доверчивым к состоянию контекста.

В ContentHub удаление статьи может быть не главным сценарием (там больше про статусы и архивирование), но как учебный пример на JPA это очень полезно.

import org.junit.jupiter.api.Test;

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

@Test
void deleteById_thenExistsShouldBeFalse() {
    // Arrange: сохранили статью, чтобы точно было что удалять
    Article saved = repository.save(draftArticle("java-basics"));
    entityManager.flush();

    // Act: удаляем и фиксируем это удаление в БД
    repository.deleteById(saved.getId());
    entityManager.flush();
    // Важно: очистка контекста, чтобы existsById не "опирался" на кеш 1-го уровня
    entityManager.clear();

    // Assert: проверяем именно факт существования записи в таблице
    assertThat(repository.existsById(saved.getId())).isFalse();
}

Почему здесь existsById(), а не findById()? Потому что вопрос теста звучит как «существует ли запись», а не «какие у неё поля». existsById() проще по смыслу и обычно проще по диагностике. Если тест падает, сообщение будет очень ясным: «ожидали false, получили true». А вы не будете отвлекаться на то, какой именно объект вернулся и какие у него поля.

Если вам хочется убедиться на уровне «пустого Optional», это тоже нормальный способ, особенно когда вы привыкли мыслить «прочитали — нет данных».

// Альтернатива: проверяем, что запись не находится (после flush + clear)
assertThat(repository.findById(saved.getId())).isEmpty();

Главное, чтобы это было после flush() и clear().

5. Дисциплина data-теста

В data-тестах есть очень опасный соблазн: раз уж мы подняли БД, давайте проверим «заодно» ещё десять вещей. У репозитория же есть save, findById, count, exists, может и findAll()… И вот тест превращается в длинное полотно, где непонятно, что именно мы защищаем. Такой тест плохо поддерживается и плохо объясняет падение: он падает «где-то», а исправлять надо «непонятно что».

Хорошая дисциплина выглядит скучно, но спасает нервы. Сначала вы формулируете вопрос теста простым русским языком. Например: «после сохранения у статьи должен сохраниться slug» или «после удаления записи не существует». Потом вы выбираете один самый прямой способ проверки и делаете его честным через flush + clear. И только после этого добавляете максимум пару дополнительных assertions, если они прямо усиливают доказательство, а не «просто раз уж мы тут».

Иногда помогает мини-правило: если вы не можете объяснить смысл каждого assertion одной фразой, значит, вы уже пишете не тест, а маленькую энциклопедию про свою сущность.

6. Типичные ошибки при честных проверках состояния БД

В этой теме ошибки особенно коварны, потому что тесты часто «зелёные» и поэтому создают ощущение, что всё сделано правильно. На самом деле они могут просто проверять поведение Java-объекта в памяти. Ниже — самые частые грабли, которые я вижу у начинающих (и, честно говоря, иногда у уставших опытных разработчиков, которые просто хотят домой).

Ошибка №1: assertions на объекте, который вернул save(), без повторного чтения.
Если вы после save() проверяете поля у saved и не делаете ни flush, ни clear, вы почти наверняка проверяете то, что сами задали сеттерами. Такой тест «проходит всегда» и поэтому особенно опасен: он выглядит полезным, но при реальном баге в мэппинге или ограничениях БД может ничего не поймать.

Ошибка №2: flush() сделали, а clear() забыли — и уверены, что “перечитали из базы”.
flush() действительно отправляет изменения в БД, но не заставляет JPA забыть уже управляемые объекты. Поэтому findById() в том же контексте может вернуть тот же экземпляр. Вы увидите «правильные поля», но не поймёте, что это не чтение из таблицы, а возвращение объекта из кэша первого уровня.

Ошибка №3: смешали в одном тесте сохранение, обновление, удаление и ещё пару проверок “на всякий случай”.
Чем больше действий в одном тесте, тем меньше вы понимаете, что именно сломалось при падении. А ещё хуже — вы начинаете «случайно» зависеть от промежуточного состояния, которое существует только в persistence context. В итоге тест становится хрупким: меняете мелочь в сущности, и падает тест, который вообще-то «про другое».

Ошибка №4: проверяют “что запись есть” через findAll().size(), хотя вопрос был про конкретную запись.
findAll выглядит удобным, но часто добавляет шум. Если вы проверяете конкретную статью, логичнее использовать findById или existsById. Если проверяете количество, логичнее count(). Лишний широкий запрос делает тест менее точным и иногда менее детерминированным, особенно если в БД появляются seed-данные.

Ошибка №5: граница записи спрятана в helper так, что тест перестаёт быть читаемым.
Helpers нужны, но они не должны превращать тест в “магическую кнопку”. Если ваш helper делает и save, и flush, и clear, и ещё find, и ещё половину assertions — тест превращается в загадку. Хороший компромисс: helper может сократить повторяющийся boilerplate (flushAndClear()), но финальное чтение и финальные assertions лучше держать на виду, в теле теста.

1
Задача
Spring Test, 15 уровень, 2 лекция
Недоступна
Честное повторное чтение после `repository.save(...)`
Честное повторное чтение после `repository.save(...)`
1
Задача
Spring Test, 15 уровень, 2 лекция
Недоступна
Подсчёт реально записанных строк через `count()`
Подсчёт реально записанных строк через `count()`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ