JavaRush /Курсы /Spring Test /Тестовая транзакция и roll...

Тестовая транзакция и rollback by default

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

1. Транзакции в data-тестах

Если вы раньше писали тесты только для контроллеров или сервисов, то транзакции могли звучать как что-то из мира «взрослых» проектов, где все ходят в костюмах и спорят про READ_COMMITTED. В @DataJpaTest транзакция появляется не для пафоса, а чтобы сделать тесты предсказуемыми: каждый тест получает маленькую песочницу в базе данных и не обязан потом подметать за собой.

Транзакция в прикладном смысле — это режим «всё или ничего». Вы внутри неё делаете несколько операций (insert/update/delete), и в конце база либо фиксирует изменения (commit), либо отменяет их (rollback), как будто вы нажали очень мощный Ctrl+Z. Для тестов это звучит идеально: мы хотим смело создавать данные для сценария, проверять поведение репозитория и в конце не оставлять мусор в базе, который поломает следующий тест.

В обычном приложении граница транзакции чаще всего живёт на уровне service-layer: сервис открыл транзакцию, вызвал репозитории, всё завершилось — транзакция закрылась. В data-slice тестах Spring делает похожую вещь, только «оборачивает» транзакцией выполнение тестового метода. То есть тест, по сути, превращается в мини-сценарий работы с базой, но без последствий для соседних тестов.

2. Rollback by default в @DataJpaTest

Когда вы слышите «rollback по умолчанию», легко представить себе, что тест вообще «не пишет в базу», а значит, всё происходит в какой-то симуляции. Это не так: SQL-запросы выполняются, сущности реально сохраняются и читаются, просто итоговое состояние откатывается в конце теста. Если бы это было не так, репозиторий-тесты не имели бы смысла — мы же как раз хотим увидеть поведение хранения, а не театральную постановку на моках.

В @DataJpaTest типичный жизненный цикл одного тестового метода выглядит примерно так: Spring Test начинает транзакцию перед выполнением метода @Test, затем вы выполняете внутри теста save, findById, count или любые другие операции репозитория, и после завершения метода Spring делает rollback. Следующий тест стартует уже в новой транзакции и не видит ваших вчерашних (точнее, «прошлотестовых») художеств.

Для визуализации удобно держать в голове простую схему:

flowchart TD
    %% Каждая вершина — это логический шаг одного тестового метода в @DataJpaTest
    A["Старт тестового метода @Test"] --> B["Spring открывает транзакцию"]
    B --> C["Arrange: готовим данные (save/persist)"]
    C --> D["Act: вызываем метод репозитория"]
    D --> E["Assert: проверяем результат"]
    E --> F["Spring делает ROLLBACK"]
    F --> G["Конец тестового метода, база в исходном состоянии"]

И вот здесь появляется важное ожидание: внутри одного теста вы видите все свои изменения, как будто они уже «в базе». Это нормально, потому что вы работаете в рамках одной транзакции. Но после завершения теста изменения исчезают. Если вы когда-то писали скрипты миграций и мечтали, чтобы ошибки можно было откатывать одним нажатием, то поздравляю: в тестах это почти так и работает.

3. Изоляция тестов: каждый @Test — отдельная история

Тесты, которые зависят друг от друга, обычно работают ровно до того момента, пока вы не запускаете их в CI, на другой машине или просто в другом порядке. А потом начинается классика жанра: «у меня зелёное, у вас красное, давайте трогать руками порядок тестов». Rollback by default — это встроенная защита от превращения test suite в мыльную оперу.

Идея простая: каждый тестовый метод должен быть самодостаточным. Он сам готовит данные, сам вызывает то, что проверяет, и сам утверждает результат. Он не должен надеяться, что «перед ним уже создали статью» или «после него кто-то почистит таблицу». В @DataJpaTest Spring помогает нам соблюдать это правило, потому что после каждого теста делает откат транзакции.

Небольшой пример на репозитории статей ContentHub. Обратите внимание: здесь два теста в одном классе, но они не связаны. Какой бы ни запустился первым, он должен быть корректным.

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.*;

@DataJpaTest // Поднимаем только JPA-срез и автоматически оборачиваем каждый тест в транзакцию
class ArticleRepositoryRollbackDataJpaTest {

    @Autowired // Spring внедрит реальный репозиторий из контекста data-slice теста
    private ArticleRepository articleRepository;

    @Test
    void savesArticle_insideTestItExists() {
        // Arrange: сохраняем статью внутри тестовой транзакции
        articleRepository.save(TestArticles.draft("spring-data-basics"));

        // Assert: внутри этой же транзакции запись видна, поэтому count() == 1
        assertThat(articleRepository.count()).isEqualTo(1);
        // Важно: после завершения теста Spring сделает rollback, и данные не «потекут» в следующий тест
    }
}

А второй тест (в том же классе) может честно ожидать, что таблица статей пуста (если вы не грузите статьи сид-данными в миграциях). Он не «надеется на прошлый тест», потому что прошлый тест уже был откатан.

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

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

class ArticleRepositoryRollbackDataJpaTest {

    @Autowired
    private ArticleRepository articleRepository;

    @Test
    void nextTest_startsCleanBecauseRollback() {
        // Assert: если у вас нет сид-данных, то после rollback прошлого теста таблица должна быть пустой
        assertThat(articleRepository.count()).isZero();
    }
}

Если у вас возникает желание сделать тесты «быстрее» за счёт того, что один тест подготовит данные, а другой ими воспользуется, это выглядит экономией только на уровне пары строк. На уровне устойчивости suite это обычно катастрофа: вы платите зависимостями между тестами, сложной диагностикой и редкими, но очень неприятными «плавающими» падениями.

4. Rollback и ArrangeActAssert

Когда тест живёт внутри транзакции, у него появляется приятная особенность: вы можете в одном сценарии и подготовить данные, и проверить их через репозиторий, не боясь «загрязнить» базу для других тестов. Но это удобство иногда провоцирует другую беду: люди начинают пихать в один тест всё подряд, потому что «ну база же откатится». Откатится-то откатится, но читать такой тест будет больно.

Самый здоровый подход — по-прежнему мыслить через ArrangeActAssert. Просто Arrange в repository-тестах — это обычно создание нескольких сущностей (статья, категория, автор), Act — вызов метода репозитория, Assert — проверка результата. Важно, что Assertions должны доказывать именно обещание вашего сценария, а не «вообще всё в мире правильно».

Пример маленького и «честного» AAA-сценария: мы хотим доказать, что поиск по slug работает.

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

import java.util.Optional;

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

class ArticleRepositoryRollbackDataJpaTest {

    @Autowired
    private ArticleRepository articleRepository;

    @Test
    void findBySlug_returnsSavedArticle() {
        // Arrange: создаём и сохраняем сущность, которая нужна для сценария
        articleRepository.save(TestArticles.draft("spring-data-basics"));

        // Act: выполняем целевое действие (вызов метода репозитория)
        Optional<Article> found = articleRepository.findBySlug("spring-data-basics");

        // Assert: проверяем только то, что обещает сценарий (нашли/не нашли)
        assertThat(found).isPresent();
    }
}

Заметьте, что этот тест не пытается одновременно доказать «и что статус DRAFT, и что автор alice, и что createdAt заполнился, и что миграции применились». Это всё может быть важным, но один тест — одна договорённость. Если вы не держите этот принцип, rollback не спасёт от деградации читаемости: тесты станут зелёными, но бесполезно толстыми.

Часто помогает писать в тесте маленькие комментарии (да, это тот редкий случай, когда комментарии в тесте — не зло, а помощь читателю):

Article saved = articleRepository.save(TestArticles.draft("spring-data-basics")); // Arrange: готовим данные
Optional<Article> found = articleRepository.findBySlug(saved.getSlug());          // Act: выполняем действие
assertThat(found).isPresent();                                                   // Assert: проверяем результат

5. Ручная очистка: когда лишняя

Первый рефлекс многих разработчиков, особенно после опыта с «ручными» интеграционными тестами: «после теста надо почистить базу». В @DataJpaTest этот рефлекс чаще всего просто не нужен. Rollback by default делает очистку за вас автоматически, и обычно гораздо быстрее и надёжнее, чем самодельный deleteAll().

Когда вы добавляете ручной cleanup, вы создаёте дополнительный шум в тестах и, что неприятнее, дополнительную нагрузку на базу. В итоге тесты могут стать медленнее, а ещё вы начинаете верить, что тесты изолированы «потому что мы чистим», хотя на самом деле изоляция уже обеспечивается транзакциями.

Вот пример «лишней уборки», которая красиво выглядит только до тех пор, пока вы не умножите её на 200 тестов:

import org.junit.jupiter.api.AfterEach;
import org.springframework.beans.factory.annotation.Autowired;

class ArticleRepositoryRollbackDataJpaTest {

    @Autowired
    private ArticleRepository articleRepository;

    @AfterEach
    void cleanup() {
        // Обычно это лишнее: @DataJpaTest сам откатит транзакцию после каждого @Test
        // А вот deleteAll() создаёт дополнительный шум и лишние запросы к базе
        articleRepository.deleteAll();
    }
}

В большинстве случаев правильнее довериться дефолту @DataJpaTest: один тест — одна транзакция — один rollback. Если вы всё же добавляете ручную очистку, стоит хотя бы честно понимать, зачем. Например, вы сознательно отключили rollback (об этом ниже) или вы создаёте данные вне транзакции (что в data-slice тестах встречается редко, но теоретически возможно).

6. Границы rollback

Rollback звучит как магия, но магия, как и в Java, заканчивается там, где вы выходите за границы контракта. Транзакция откатывает изменения в базе данных в рамках этой транзакции, но не «откатывает всю вселенную». Если вы ожидаете, что rollback вернёт к исходному состоянию абсолютно всё, можно довольно быстро получить странные и раздражающие падения.

Полезно держать в голове короткую таблицу. Она не для заучивания, а для того, чтобы мозг не строил ложных предположений.

Что вы делаете в тесте Откатится rollback-ом? Почему так
save() статьи через репозиторий (insert/update) Да Изменения в БД идут в транзакции теста
delete() через репозиторий Да Это тоже DML в рамках транзакции
Чтение данных (findById, findBySlug) Не «откатывается», но и не меняет БД Чтение само по себе не оставляет следов
Данные, загруженные при старте контекста (миграции/сид-данные) Нет Они появились до транзакции тестового метода
Изменения в последовательностях/генераторах id Может быть «не как вы ждёте» В некоторых СУБД генерация id может жить своей жизнью
Запись файла на диск (если бы вы делали I/O) Нет Файловая система не участвует в транзакции БД

Самый частый «сюрприз» — это попытка привязаться к конкретному значению id, например ожидать, что первая статья в тесте всегда получит id = 1. На одних базах это случайно совпадает, на других — нет, а в третьих будет зависеть от того, как именно настроена генерация идентификаторов. Поэтому правило простое: в repository-тестах почти всегда проверяют, что id не null (или что он положительный), а не что он равен конкретному числу.

Вот пример плохой идеи, которая может сделать тест хрупким:

import org.junit.jupiter.api.Test;

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

class ArticleRepositoryRollbackDataJpaTest {

    @Test
    void doNotAssertExactIds() {
        // articleRepository здесь подразумевается как внедрённый Spring-ом репозиторий (как в примерах выше)
        Article saved = articleRepository.save(TestArticles.draft("spring-data-basics"));

        // Assert: хрупко и часто бессмысленно — id может зависеть от СУБД/стратегии генерации/порядка запусков
        assertThat(saved.getId()).isEqualTo(1L);
    }
}

Гораздо здоровее так:

assertThat(saved.getId()).isNotNull();

Rollback также не означает, что «внутри теста базы как бы нет». База есть, JPA есть, репозиторий есть, и именно поэтому @DataJpaTest ловит проблемы мэппинга и запросов. Просто после теста всё возвращается в исходное состояние, чтобы следующий тест не жил на руинах предыдущего.

7. Отключение rollback: цена и последствия

Иногда возникает желание «закоммитить изменения» в тесте. Обычно это желание рождается не из необходимости, а из усталости: хочется подготовить данные один раз и дальше читать их в других тестах. Но это как держать квартиру в чистоте, просто запихивая всё под диван: сначала кажется удобно, а потом становится страшно заглянуть внутрь.

Технически Spring Test позволяет отключить откат и заставить транзакцию коммититься. Чаще всего для этого используют @Rollback(false) (или эквивалентные механики). Но важно понимать цену: вы сразу теряете автоматическую изоляцию, и вам придётся либо вручную чистить базу, либо жить с тестами, которые влияют друг на друга.

Мини-пример того, как это выглядит (показываю именно как механизм, а не как рекомендацию для повседневного использования):

import org.junit.jupiter.api.Test;
import org.springframework.test.annotation.Rollback;

class ArticleRepositoryRollbackDataJpaTest {

    @Test
    @Rollback(false) // Отключаем rollback: изменения будут закоммичены, и это «протечёт» в другие тесты
    void commitsChanges_notRecommendedByDefault() {
        // Arrange/Act: записываем данные в БД уже «по-настоящему»
        articleRepository.save(TestArticles.draft("spring-data-basics"));

        // Assert здесь обычно не делают: пример показывает именно механизм (и его цену), а не хороший тест-кейс
    }
}

В большинстве случаев для repository-layer тестов ContentHub это не нужно. Если тесту нужны данные, он должен создать их сам в Arrange-части. Это повышает локальность сценария и делает диагностику проще: тест упал — вы открыли его и сразу видите все предпосылки, а не ищете «кто там до него что коммитил».

8. Типичные ошибки при @DataJpaTest

Ошибка №1: делать один тест «подготовительным», а другой — «проверочным».
Это выглядит как маленькая оптимизация: «в одном тесте создам статью, а в другом проверю findBySlug». Но из-за rollback by default вы внезапно обнаружите, что «проверочный» тест не находит данных. И это как раз правильное поведение: тесты не должны быть связаны. Если вам нужно состояние — готовьте его в этом же тесте, чтобы сценарий был самодостаточным.

Ошибка №2: ожидать, что rollback делает тест “не настоящим”, и поэтому не доверять результатам.
Иногда студенты думают так: «Раз откат, значит база не участвует, значит это всё не серьёзно». На самом деле @DataJpaTest работает с реальным persistence layer: поднимается JPA, создаётся схема, выполняются запросы. Откат нужен только для чистоты окружения после теста, а не для симуляции.

Ошибка №3: привязываться к конкретным значениям id и другим “случайно стабильным” деталям.
Когда тест пишет assertThat(saved.getId()).isEqualTo(1L), он превращает поведение генератора идентификаторов в часть контракта домена. Обычно это не контракт. Из-за этого тесты ломаются от мелких изменений конфигурации базы или стратегии генерации id, хотя реальный функциональный риск не изменился.

Ошибка №4: добавлять deleteAll() «на всякий случай» и считать это нормой.
Rollback уже изолирует тесты. Ручная очистка часто только замедляет suite, добавляет шум и может маскировать странные эффекты: вы не замечаете, что где-то есть лишний insert/delete, потому что «мы же всё чистим». В data-slice тестах лучше держаться простого правила: готовим данные в Arrange, проверяем, доверяем откату.

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

1
Задача
Spring Test, 14 уровень, 1 лекция
Недоступна
Rollback между двумя тестами для `TagRepository`
Rollback между двумя тестами для `TagRepository`
1
Задача
Spring Test, 14 уровень, 1 лекция
Недоступна
Каждый тест видит только своё состояние
Каждый тест видит только своё состояние
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ