1. Стартовый пакет тестов
Если вы когда-нибудь пытались «написать все тесты сразу», то вы знаете этот сюжет: в начале энтузиазм, через час — грусть, через два — кофе, через три — философские вопросы к жизни и Hibernate. Поэтому старт data-layer лучше делать так же, как мы делали web-layer: небольшими, понятными, проверяемыми шагами. Первый пакет репозиторных тестов должен дать вам минимальную уверенность, что база «видит» вашу сущность Article и что основные сценарии чтения/записи работают, не превращая тестовый класс в роман на тысячу страниц.
К этому моменту у нас уже есть две опоры: понятно, какие договорённости ArticleRepository действительно стоят отдельного теста, и понятно, как готовить данные через TestEntityManager, не превращая setup во второй тест репозитория. Теперь из этих кусков можно собрать первый цельный ArticleRepositoryDataJpaTest — небольшой класс, который даёт стартовую уверенность в слое хранения и не шумит лишними деталями.
В ContentHub у статьи есть смысловые поля, без которых домен не домен: slug, status, authorUsername (и, конечно, связь с категорией). И есть простой факт, который мы обязаны доказать себе в самом начале: «я могу сохранить статью, я могу прочитать её обратно, и мой репозиторий не врёт хотя бы в базовых вещах». Да, звучит как минимализм, но это тот минимализм, который спасает нервы.
Ещё одна важная причина держать стартовый пакет маленьким — скорость. @DataJpaTest быстрее, чем полный контекст, но всё равно дороже unit-теста. Если на старте вы напишете 25 тестов в одном классе (ещё и с огромным setup’ом), вы получите медленный suite и ощущение, что «тесты — это боль». Нам нужно противоположное: быстрый feedback и понятные падения.
2. Каркас ArticleRepositoryDataJpaTest
Любой хороший тест начинается не с героических assertions, а с правильной «рамки». Для data-layer рамка — это @DataJpaTest, а также дисциплина: тест в пакете репозиториев, а не где-то «в тестах вообще». Идея простая: когда вы через месяц откроете проект, вы должны моментально понять, что это именно data slice тест, который не имеет отношения к контроллерам, JSON и статус-кодам.
Ниже — минимальный скелет тестового класса. Здесь есть две зависимости: сам ArticleRepository, который мы проверяем, и TestEntityManager, который мы используем как удобный инструмент подготовки состояния.
package com.example.contenthub.repository;
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 articleRepository; // Репозиторий, поведение которого проверяем в тестах
@Autowired
TestEntityManager entityManager; // Удобный способ подготовить данные (persist/flush) без отдельного репозитория
}
Обратите внимание на психологический эффект: когда вы видите @DataJpaTest, мозг должен сразу переключаться в режим «я тестирую persistence behavior». Если внутри этого класса вдруг появляется MockMvc или сервисы — это почти всегда сигнал, что вы склеили слои.
Чуть ниже — пример того, как обычно выглядит репозиторий, для которого мы пишем этот тест. Нам здесь важна одна вещь: метод чтения по slug, потому что это доменно значимый ключ.
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleRepository extends JpaRepository<Article, Long> {
// Доменно значимый способ чтения статьи: slug используется в URL и в сценариях редакторов
Optional<Article> findBySlug(String slug);
}
3. Минимальный fixture для Article
Сущности почти всегда устроены так, что «просто создать объект» не получается: нужно заполнить обязательные поля, связи, иногда даты. И на этом месте начинающие тестировщики JPA чаще всего ловят первую «магическую» ошибку: тест падает, но не потому, что репозиторий плохой, а потому, что мы создали невалидную статью. Это довольно обидно: вы хотели проверить save, а проверили свою невнимательность.
В ContentHub статья связана с категорией, а категория — это не просто строка, а отдельная сущность. Поэтому самый честный и понятный подход для стартового пакета — сделать в тесте небольшие helper-методы: один создаёт и сохраняет категорию, второй создаёт черновик статьи с минимальным набором полей. Мы не строим здесь «фабрику тестовых данных имени завода», но и не копипастим 12 строк в каждый тест.
Вот пример helper’а для категории. Мы сохраняем её через persist(...), чтобы сразу получить managed-объект (и не тянуть в тест CategoryRepository, если он не нужен для цели).
import com.example.contenthub.entity.Category;
// Helper внутри тестового класса: создаём и сохраняем Category, чтобы Article был валидным
private Category persistedCategory(String code) {
Category c = new Category();
c.setCode(code); // Доменный код категории (условно: "java", "spring", "db")
c.setName(code.toUpperCase()); // Просто человекочитаемое имя для полноты сущности
return entityManager.persist(c); // persist -> получаем managed-объект и заполненный id (если он генерируется)
}
А вот helper для статьи. Обратите внимание: мы осознанно делаем её черновиком (DRAFT), задаём автора и обязательно привязываем категорию. Поля summary и body — просто «минимально не пустые», чтобы сущность выглядела как статья, а не как заметка «потом допишу».
import com.example.contenthub.entity.Article;
import com.example.contenthub.entity.ArticleStatus;
// Helper внутри тестового класса: создаём минимально валидную статью (без этого тесты будут падать не по делу)
private Article draftArticle(String slug, Category category) {
Article a = new Article();
a.setTitle("Spring Data basics"); // Не ключевое поле теста, но часто обязательное/полезное для валидности
a.setSlug(slug); // Ключевое поле домена: читаем по нему через findBySlug(...)
a.setSummary("Short summary"); // Минимально непустое значение
a.setBody("Body text"); // Минимально непустое значение
a.setStatus(ArticleStatus.DRAFT); // Явно фиксируем статус, чтобы тест не зависел от дефолтов
a.setAuthorUsername("alice"); // Автор важен для доменных сценариев
a.setCategory(category); // Связь обязательна: статья без категории часто невалидна
return a;
}
Да, это не идеальный доменный builder, и да — в реальном проекте у вас могут быть фабрики или статические методы. Но для первого пакета repository-тестов такой подход хорош тем, что он прозрачен: вы буквально видите, какая статья попадает в базу.
4. Тест №1: сохранение и базовое состояние
Первый тест в репозиторном пакете должен быть максимально «скучным» и максимально полезным. Мы не проверяем тут бизнес-оркестрацию и не строим жизненный цикл статьи — мы просто доказываем, что save действительно сохраняет сущность так, как ожидает домен. Самые базовые ожидания: у сохранённой статьи появился id, slug сохранился, а статус соответствует черновику.
Обратите внимание на структуру AAA (Arrange–Act–Assert). В Arrange мы готовим категорию (иначе статья будет «полуфабрикатом»), в Act вызываем save, в Assert проверяем пару смысловых вещей — не 20 полей, а ровно то, что даёт уверенность, что запись «не странная».
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
@Test
void savesDraftArticle() {
// Arrange: обязательная связанная сущность
Category java = persistedCategory("java");
// Act: сохраняем черновик через репозиторий
Article saved = articleRepository.save(draftArticle("spring-data-basics", java));
// Assert: проверяем несколько "якорных" фактов, которые действительно дают уверенность
assertThat(saved.getId()).isNotNull(); // id должен появиться после сохранения
assertThat(saved.getSlug()).isEqualTo("spring-data-basics"); // slug не должен "потеряться"
assertThat(saved.getStatus()).isEqualTo(ArticleStatus.DRAFT); // статус соответствует ожиданию фикстуры
}
Это стартовая sanity-проверка внутри текущего persistence context. Она показывает, что репозиторий и JPA wiring живы и согласованы, но ещё не доказывает самый жёсткий сценарий «записали, вытолкнули изменения в БД, перечитали заново». Для такой проверки уже приходится внимательнее работать с flush/clear и состоянием persistence context.
Здесь легко впасть в крайность и начать проверять всё: createdAt, updatedAt, version, ещё и «пусть title точно совпадает». Но на старте лучше держать тест коротким: если save вообще не работает, вы и так это увидите. А если работает — вам достаточно нескольких якорных проверок, чтобы тест стал полезным, а не шумным.
5. Тест №2: чтение по id
Следующий шаг — доказать «read-after-write» внутри одного data-slice теста. Это не «интеграционный тест всей системы», это всего лишь базовая проверка, что метод чтения (findById) способен вернуть сохранённую сущность. И да, findById — это стандартный CRUD-метод, но он настолько фундаментален, что стартовый пакет имеет право один раз его зафиксировать, особенно если вы заодно проверяете, что возвращаются доменно важные поля.
Здесь мы проверим хотя бы authorUsername. Почему именно его? Потому что автор — это ключевое поле домена ContentHub: editor’ы работают со «своими» статьями, и это поле участвует в реальных сценариях.
import java.util.Optional;
import org.junit.jupiter.api.Test;
@Test
void readsSavedArticleById() {
// Arrange: сохраняем валидную статью
Category java = persistedCategory("java");
Article saved = articleRepository.save(draftArticle("spring-data-basics", java));
// Act: читаем по техническому ключу (id) стандартным CRUD-методом
Optional<Article> reloaded = articleRepository.findById(saved.getId());
// Assert: сущность найдена и содержит доменно важное поле
assertThat(reloaded).isPresent();
assertThat(reloaded.orElseThrow().getAuthorUsername()).isEqualTo("alice");
}
Важно понимать, что внутри @DataJpaTest вы находитесь в транзакции. Это удобно, потому что всё делается «внутри теста» и не требует ручной уборки данных. Но это же означает, что persistence context активно помогает вам. Для стартового пакета нас устраивает именно такая базовая проверка read-after-write: она быстро показывает, что репозиторий умеет вернуть сохранённую сущность и не теряет доменно важные поля. Более жёсткое доказательство физического round-trip до БД — уже отдельная задача.
6. Тест №3: slug не найден — пусто
Если вы пишете только happy path, тесты получаются как новогодняя открытка: красиво, но реальность всё равно придёт со своими проблемами. Поэтому третий тест в стартовом пакете — обязательно negative case. Мы фиксируем контракт: если статьи с таким slug нет, репозиторий возвращает Optional.empty(). Это одновременно и полезно, и очень дешево.
Самое приятное здесь — тест вообще не требует setup’а. Нам не нужно ничего сохранять в базу, чтобы проверить отсутствие данных: пустая база в начале теста — нормальное состояние благодаря rollback by default (и благодаря тому, что каждый тест независим).
import org.junit.jupiter.api.Test;
@Test
void returnsEmptyForUnknownSlug() {
// Assert: отсутствие данных — тоже контракт. Здесь intentionally нет Arrange/Act с сохранением.
assertThat(articleRepository.findBySlug("unknown-slug")).isEmpty();
}
Этот тест выглядит смешно коротким. Но именно такие тесты часто спасают вас от регрессий вида «мы случайно поменяли query и теперь он возвращает что-то странное вместо empty». На уровне репозитория «пусто» — это важный результат, а не «ну, там где-то null будет».
Повторяющийся setup: helpers и константы
В какой-то момент вы заметите, что даже для трёх тестов вы повторяете один и тот же slug, одного и того же автора, одно и то же создание категории. И тут рука тянется сделать универсальный DSL уровня «а давайте напишем aDraft().withSlug().withCategory()». Это может быть хорошей идеей — но не сегодня. Сегодня нам важно удержать тесты простыми, потому что мы ещё учимся читать JPA-тесты как сценарии.
Достаточно маленьких helpers прямо в тест-классе и пары констант. Такой компромисс работает хорошо: тесты становятся короче, но вы всё ещё видите, какие данные используются. Например, можно вынести автора и базовый slug в константы, чтобы они не «плавали» по тестам как случайные строки.
private static final String AUTHOR = "alice";
private static final String SLUG = "spring-data-basics";
private Article draftArticle(String slug, Category category) {
Article a = new Article();
a.setSlug(slug); // slug оставляем параметром, чтобы тесты могли варьировать сценарии
a.setAuthorUsername(AUTHOR); // константа: автор одинаковый во всём стартовом пакете
a.setStatus(ArticleStatus.DRAFT); // важно явно фиксировать статус
a.setCategory(category); // без категории сущность может быть невалидна
return a;
}
Да, здесь мы убрали часть полей ради краткости примера. В вашем реальном проекте вы заполните ровно те поля, которые обязательны для корректного сохранения сущности. Важно другое: helpers должны помогать тесту, а не превращать тест в «вызов фреймворка тестовых данных».
7. Карта стартовых тестов
Чтобы repository-тесты не превратились в коллекцию «потому что могу», полезно держать в голове маленькую матрицу: что мы проверяем и какой риск этим закрываем. Это особенно важно для начинающих, потому что иначе есть соблазн тестировать всё подряд: «а давайте проверим count, exists, findAll…» — и вот уже вечер, а полезность сомнительная.
Ниже — компактная таблица для нашего первого пакета. Она не претендует на универсальность, но она помогает увидеть логику: каждый тест доказывает одно обещание репозитория.
| Тест | Что доказываем | Какой риск ловим | Минимальный setup |
|---|---|---|---|
| savesDraftArticle | сущность сохраняется и получает id | запись «не работает вообще», неверный базовый статус | категория + черновик |
| readsSavedArticleById | чтение по id возвращает статью | репозиторий не возвращает сущность после сохранения | категория + сохранение |
| returnsEmptyForUnknownSlug | чтение по slug корректно возвращает пусто | неверный контракт чтения, «странные» результаты вместо empty | не нужен |
Если держать эту матрицу в голове, то становится проще не скатиться в «тестируем фреймворк». Мы тестируем поведение нашего слоя хранения в контексте домена ContentHub.
Чтобы закрепить структуру сценария, можно представить data-test как простую линию: готовим состояние → вызываем метод репозитория → проверяем результат. Это выглядит почти по-детски, но именно такая «детскость» делает тесты читаемыми.
flowchart LR
A["Arrange: persist Category / подготовить Article"] --> B["Act: вызвать метод ArticleRepository"]
B --> C["Assert: проверить Optional / id / slug / status"]
8. Типичные ошибки в @DataJpaTest
Ошибка №1: тащить в @DataJpaTest сервисы и контроллеры.
Когда вы пишете первые data-layer тесты, мозг обычно ещё живёт в режиме MVC-тестов или unit-тестов, и поэтому ошибки очень характерные. Часто ожидают, что в @DataJpaTest будут доступны сервисы или контроллеры, и начинают тянуть их в контекст «просто чтобы работало». В итоге вы теряете идею slice-теста и получаете дорогой, расползающийся контекст, который сложнее поддерживать и сложнее дебажить.
Ошибка №2: невалидная фикстура (например, статья без категории).
Другая распространённая история — создать Article без категории (или без обязательных полей) и затем долго смотреть на падение теста как на «проблему репозитория». На практике это проблема фикстуры: вы построили объект, который не может быть сохранён корректно. Для стартового пакета лучше быть скучным и честным: создать минимально валидную сущность, пусть даже вручную.
Ошибка №3: один тест «про всю жизнь статьи».
Ещё одна ловушка — попытка проверить «всё сразу» в одном тесте. Например, сохранить статью, прочитать по id, прочитать по slug, обновить, удалить и проверить count. Такой тест выглядит экономным, но он очень плохо диагностируется: упало — и непонятно, что именно сломалось. Гораздо полезнее три коротких теста с одним обещанием каждый, чем один длинный тест «про всю жизнь статьи».
Ошибка №4: проверять только id и ничего больше.
Иногда также встречается обратная крайность: проверять только id и ничего больше. Да, id — это важно, но если вы не проверяете ни одного смыслового поля, вы получаете тест, который зелёный даже при неприятных регрессиях (например, когда статус статьи по умолчанию неожиданно поменялся). В repository-тестах ценность обычно именно в смысловых якорях вроде slug, status, authorUsername, а не только в техническом факте «запись существует».
Ошибка №5: мокать репозиторий в repository-тесте.
И последний классический промах — мокать репозиторий в repository-тесте. Это звучит как шутка, но иногда действительно делают @MockBean ArticleRepository articleRepository; и потом удивляются, что «всё зелёное». Конечно зелёное: вы только что удалили из теста то, что он должен проверять. @DataJpaTest и так максимально узкий и быстрый для своего слоя — здесь моки обычно не нужны и даже вредны.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ