1. Mapping-тест: поведение, не аннотации
Если вы только начинаете, есть опасная иллюзия: «я поставил @ManyToOne, значит связь есть». Это примерно как наклеить на ноутбук стикер “Gaming PC” и ожидать +30 FPS в жизни. ORM работает не по наклейкам, а по тому, как реально устроены таблицы, foreign key, nullable-колонки и кто “владеет” связью. Поэтому mapping-тест — это не про “сходится ли красивый код”, а про “переживает ли модель реальный цикл сохранения/чтения”.
Когда мы говорим «протестировать mapping», мы обычно хотим доказать одну из очень конкретных вещей. Например: “если я сохранил Product с категорией, то после повторного чтения из БД у продукта действительно есть категория”. Или: “если я сохранил CustomerOrder с одной позицией, то эта позиция не потерялась, и FK указывает на заказ”. Или: “если я удалил позицию из заказа, то она реально исчезла из таблицы order_item”.
Полезно держать в голове простую «формулу честного mapping-теста»:
flowchart TD
A[Arrange: создаём entity-объекты] --> B[Act: сохраняем через repository]
B --> C[flush: заставляем SQL уйти в БД]
C --> D[clear: чистим persistence context]
D --> E[Assert: читаем заново и проверяем факт в БД]
Заметьте, в конце мы проверяем не “поле в объекте”, а последствие в базе, которое проявляется через повторное чтение.
2. Инструменты mapping-теста: findById(...), flush(), clear()
Механику flush() и clear() мы уже разобрали на JPA-slice. Здесь важен только прикладной смысл: mapping-тест должен пройти цикл save → flush → clear → reread, иначе слишком легко проверить красивый граф объектов в памяти вместо факта в таблицах.
Для повторного чтения обычно достаточно repository.findById(...). Это нормально, потому что в этой лекции мы не тестируем запросы. Нам важно честно проверить, что FK, owning side и каскады пережили запись и потом восстановились из БД.
Пример заготовки тестового класса в стиле нашего проекта shop-data-jpa:
package com.example.shopdatajpa.ordering.repository;
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;
@DataJpaTest
class CustomerOrderMappingTest {
@Autowired
EntityManager entityManager; // Доступ к flush/clear и низкоуровневым проверкам БД
@Autowired
CustomerOrderRepository orderRepository; // Репозиторий — точка входа для сохранения/чтения aggregate root
@Test
void smoke() {
// тело тестов будет дальше
}
}
И вот тот самый “контрольный жест”, который делает тест честнее:
entityManager.flush(); // Важно: заставляем Hibernate реально отправить SQL в БД
entityManager.clear(); // Важно: очищаем persistence context, чтобы при чтении не вернуть объект "из памяти"
flush() и clear() не нужны в каждом тесте как ритуал. Но там, где вы хотите доказать поведение БД, а не текущее состояние managed-объектов, без них легко обмануть самого себя.
3. ManyToOne: Product → Category и проверка FK
Начнём с самого понятного вида связи, потому что он отлично показывает смысл “маппинга как контракта”. В домене mini-shop категория — справочник, товар на неё ссылается. В таблицах это выглядит как foreign key product.category_id → category.id. В объектной модели — Product.getCategory(). В тесте мы хотим доказать, что после сохранения товара связь реально сохраняется и переживает повторное чтение.
Предположим, у нас есть сущности Category и Product, а также репозитории CategoryRepository и ProductRepository. Тест будет выглядеть примерно так: сначала сохраняем категорию (как независимую сущность), затем создаём продукт и указываем категорию, затем сохраняем продукт, делаем flush + clear, и читаем продукт заново.
package com.example.shopdatajpa.catalog.repository;
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 static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ProductCategoryMappingTest {
@Autowired EntityManager entityManager; // flush/clear: делаем проверку "по-настоящему через БД"
@Autowired CategoryRepository categoryRepository; // Справочник категорий (обычно живёт отдельно от продуктов)
@Autowired ProductRepository productRepository; // Продукты ссылаются на категорию через FK
@Test
void productKeepsCategoryAfterReload() {
// Arrange: сначала создаём и сохраняем категорию как независимую сущность
Category tea = categoryRepository.save(new Category("tea", "Tea"));
// Arrange: создаём продукт и привязываем к уже сохранённой категории
Product product = new Product("SKU-1", "Green Tea");
product.setCategory(tea);
// Act: сохраняем продукт (должен записать category_id)
productRepository.save(product);
// Assert (через БД): выталкиваем SQL и "забываем" объекты из persistence context
entityManager.flush();
entityManager.clear();
// Assert: читаем заново и проверяем, что связь реально восстановилась из БД
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
assertThat(reloaded.getCategory().getCode()).isEqualTo("tea");
}
}
Что именно мы доказали этим тестом? Мы доказали, что связь не “нарисована в памяти”, а реально выражена в базе. Если у вас где-то перепутан @JoinColumn, если колонка не та, если FK не создаётся или nullable не соответствует ожиданию, вы узнаете об этом здесь, а не через неделю в самом неподходящем месте.
Типичная школьная (в смысле “очень распространённая”) ошибка — попытаться в таком тесте сохранить продукт с новой категорией без сохранения категории, надеясь на каскад. В большинстве моделей ManyToOne к справочнику не должен каскадиться, и тест как раз помогает это почувствовать руками.
4. Двусторонняя связь: CustomerOrder и OrderItem
Двусторонние связи — главный “генератор удивления” у начинающих. Причина простая: у вас как будто две правды. С одной стороны, у заказа есть коллекция items. С другой стороны, у позиции есть поле order. И если вы обновили только одну сторону, вторая сама “магически” не подтянется — увы, это не React, и state management тут не встроенный.
Поэтому первый слой тестирования таких связей — даже не БД, а helper-методы, которые держат обе стороны синхронизированными. В проекте мы почти наверняка делали что-то такое:
public void addItem(OrderItem item) {
items.add(item); // Важно: обновляем коллекцию на стороне заказа
item.setOrder(this); // Важно: обновляем обратную ссылку на стороне позиции
}
Это можно тестировать даже без репозитория — просто как обычный метод, потому что он предотвращает десятки “странных” багов позже.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerOrderHelpersTest {
@Test
void addItemKeepsBothSidesInSync() {
// Arrange: создаём доменные объекты, без БД и без ORM
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
OrderItem item = new OrderItem();
// Act: используем helper-метод, который обязан держать граф объектов согласованным
order.addItem(item);
// Assert: проверяем обе стороны связи в памяти
assertThat(order.getItems()).contains(item);
assertThat(item.getOrder()).isSameAs(order);
}
}
Но мы сегодня в контуре @DataJpaTest, поэтому обязательно добавим второй слой: проверим, что связь переживает flush + clear.
Чтобы тест был реалистичным, нам нужен хотя бы один Product, потому что OrderItem ссылается на Product. Здесь важно не раздувать fixture: мы создаём минимально нужные сущности, которые дают нам “отличимый” результат.
package com.example.shopdatajpa.ordering.repository;
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 java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class CustomerOrderItemsMappingTest {
@Autowired EntityManager entityManager; // flush/clear: принуждаем тест проверять именно БД
@Autowired CustomerOrderRepository orderRepository; // Сохраняем/читаем заказ как корень агрегата
@Autowired ProductRepository productRepository; // Нужен, чтобы создать Product для OrderItem
@Test
void orderKeepsItemsAfterReload() {
// Arrange: создаём минимальный продукт, чтобы OrderItem мог на него ссылаться
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: создаём заказ и добавляем позицию через helper-метод (две стороны должны синхронизироваться)
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
order.addItem(new OrderItem(product, 2, new BigDecimal("10.00")));
// Act: сохраняем заказ (ожидаем, что маппинг и каскады настроены корректно)
orderRepository.save(order);
// Assert (через БД): фиксируем в БД и затем читаем с "чистого листа"
entityManager.flush();
entityManager.clear();
// Assert: после повторного чтения связи должны восстановиться из таблиц
CustomerOrder reloaded = orderRepository.findById(order.getId()).orElseThrow();
assertThat(reloaded.getItems()).hasSize(1);
assertThat(reloaded.getItems().get(0).getProduct().getSku()).isEqualTo("SKU-1");
}
}
Обратите внимание на скрытую важность этого теста: мы не просто проверили hasSize(1). Мы проверили, что цепочка Order → items → product реально существует после повторного чтения. Это как раз тот случай, когда “проверить только размер” слишком слабо: размер может быть правильным даже при частично сломанной связи, а вот конкретное поле продукта даёт нам уверенность, что всё связалось по-настоящему.
5. cascade: корень сохраняет дочерние записи
cascade — это механизм, из-за которого новички чаще всего либо радуются (“ура, само сохранилось!”), либо пугаются (“ой, само удалилось!”). В нормальном проекте каскадирование должно соответствовать жизненному циклу. У заказа и позиций жизненный цикл общий: позиция почти никогда не живёт сама по себе без заказа, значит cascade здесь логичен.
Тест на cascade должен отвечать на очень конкретный вопрос: достаточно ли сохранить корень (CustomerOrder), чтобы дочерние OrderItem реально появились в БД? И здесь снова важно не перепутать “в памяти они есть” с “в таблице они есть”.
Один из хороших способов проверить каскад — взять ID дочерней сущности после flush, очистить контекст, и попытаться найти её напрямую через EntityManager.find(...). Это выглядит немного “низкоуровнево”, но зато очень честно и не тащит в тест лишние репозитории.
package com.example.shopdatajpa.ordering.repository;
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 java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class CascadePersistTest {
@Autowired EntityManager entityManager; // Нужен, чтобы напрямую проверить запись в таблице через find(...)
@Autowired CustomerOrderRepository orderRepository; // Сохраняем "корень"
@Autowired ProductRepository productRepository; // Создаём продукт для позиции
@Test
void savingOrderAlsoPersistsItems() {
// Arrange: продукт должен существовать, чтобы FK из OrderItem был валиден
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: создаём заказ и позицию, позицию добавляем через helper
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
order.addItem(new OrderItem(product, 1, new BigDecimal("10.00")));
// Act: сохраняем только заказ (если cascade настроен, позиции тоже должны сохраниться)
orderRepository.save(order);
// Flush нужен, чтобы позиции получили ID и реально появились в БД
entityManager.flush();
// Запоминаем ID дочерней сущности, чтобы проверить её существование отдельно от графа объектов
Long itemId = order.getItems().get(0).getId();
// Очищаем контекст, чтобы следующий find не вернул "тот же объект из памяти"
entityManager.clear();
// Assert: позиция должна находиться напрямую по ID
OrderItem found = entityManager.find(OrderItem.class, itemId);
assertThat(found).isNotNull();
}
}
Если каскада нет, то часто произойдёт одно из двух: либо при flush() Hibernate скажет что-то вроде “transient instance must be saved before current operation” (потому что вы прикрепили несохранённую позицию к заказу), либо позиция не получит ID и не окажется в БД. И в обоих случаях тест хорош: он ловит проблему на уровне data-model, а не на уровне “у меня в сервисе почему-то не работает placeOrder”.
6. orphanRemoval: удаление сирот
orphanRemoval — это следующий уровень после каскада. Каскад помогает сохранять/удалять “по цепочке” при операциях с родителем. А orphanRemoval означает: если элемент убрали из коллекции родителя, он считается «сиротой» и должен быть удалён из базы. Это важная бизнес-семантика для заказа: если вы удалили позицию из заказа, она не должна остаться “болтаться” в таблице order_item.
Главная методическая ловушка: проверить orphanRemoval “в памяти” очень легко, но это ничего не доказывает. Вы убрали элемент из списка — список стал меньше. Поздравляю, вы доказали работу ArrayList, а не работу ORM. Поэтому для orphanRemoval обязательно нужен flush и лучше clear, а проверка должна идти через факт в БД.
Нам понадобится последовательность шагов: сохранить заказ с одной позицией, зафиксировать ID позиции, убрать позицию из заказа, сделать flush, очистить контекст, и проверить, что entityManager.find(OrderItem.class, itemId) возвращает null.
package com.example.shopdatajpa.ordering.repository;
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 java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class OrphanRemovalTest {
@Autowired EntityManager entityManager; // Проверяем факт в БД через find(...)
@Autowired CustomerOrderRepository orderRepository; // Сохраняем/читаем заказ
@Autowired ProductRepository productRepository; // Создаём продукт для позиции
@Test
void removingItemFromOrderDeletesItFromDb() {
// Arrange: создаём продукт
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: создаём заказ и позицию
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
OrderItem item = new OrderItem(product, 1, new BigDecimal("10.00"));
order.addItem(item);
// Act: сохраняем заказ и получаем ID позиции
orderRepository.save(order);
entityManager.flush(); // Важно: ID у item появляется после flush
Long itemId = item.getId();
// Act: удаляем позицию из заказа через helper (он должен разорвать связь с обеих сторон)
order.removeItem(item); // важно: helper-метод синхронизирует обе стороны
// Assert (через БД): фиксируем изменения и читаем "вне контекста"
entityManager.flush();
entityManager.clear();
// Assert: "сирота" должна исчезнуть из таблицы
assertThat(entityManager.find(OrderItem.class, itemId)).isNull();
}
}
Почему здесь важен helper-метод removeItem()? Потому что в двусторонней связи вы обязаны синхронизировать и коллекцию, и обратную ссылку. Правильный removeItem обычно делает две вещи: удаляет из order.getItems() и ставит item.setOrder(null). Если вы сделаете только одно, поведение может стать «странным»: в памяти вы думаете, что удалили, а Hibernate на flush увидит другое состояние графа.
И ещё один нюанс, который полезно осознать: orphanRemoval — это не “вежливая просьба”. Это прямой контракт: разорвали связь — запись должна исчезнуть. Если это не соответствует вашей предметной области, orphanRemoval лучше не включать. Но если соответствует — тест на orphanRemoval превращается в ваш “ремень безопасности”.
Мини-кейс OneToOne: Product → StockItem
Связь OneToOne выглядит красиво на диаграмме, но в базе она держится на очень приземлённых вещах: уникальности внешнего ключа и дисциплине “кто владеет связью”. В нашем проекте StockItem — учёт остатков, и на один Product должен быть ровно один StockItem. В тесте мы хотим доказать, что StockItem действительно находится через продукт после повторного чтения.
Пример в упрощённом виде (мы не тестируем бизнес-логику остатков, только mapping):
package com.example.shopdatajpa.inventory.repository;
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 static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ProductStockItemMappingTest {
@Autowired EntityManager entityManager; // flush/clear: читаем "как из настоящей БД"
@Autowired ProductRepository productRepository; // Создаём продукт
@Autowired StockItemRepository stockItemRepository; // Создаём запись остатков, связанную с продуктом
@Test
void stockItemIsReachableFromProductAfterReload() {
// Arrange: создаём продукт
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: создаём остатки для продукта (владение связью зависит от вашей модели)
stockItemRepository.save(new StockItem(product, 10, 0));
// Assert (через БД): фиксируем и читаем заново
entityManager.flush();
entityManager.clear();
// Assert: после повторного чтения StockItem должен быть доступен из Product
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
assertThat(reloaded.getStockItem().getAvailableQuantity()).isEqualTo(10);
}
}
Да, здесь мы используем отдельный репозиторий StockItemRepository. Это нормально в mapping-тестах, потому что нам нужно создать корректное состояние базы. Если в вашей модели настроен каскад от Product к StockItem, тогда тест можно упростить и сохранять только продукт; но чаще в реальной жизни каскад для OneToOne нужно включать очень осознанно, поэтому оба варианта допустимы — главное, чтобы тест доказывал реальное поведение после flush + clear.
7. Типичные ошибки при mapping-тестах
Ошибка №1: тест без повторного чтения.
Такой тест обычно доказывает только состояние Java-объектов, которые вы только что создали. ORM при этом мог вообще ничего не записать в БД (или записать не так), а вы этого не увидели, потому что persistence context “держит” объект и возвращает его же. Если хочется проверить mapping, нужно заставить SQL уйти (flush) и затем читать заново после очистки контекста (clear).
Ошибка №2: изменение только inverse side в двусторонней связи.
Новички часто добавляют item в order.getItems() и думают, что item.setOrder(order) “как-нибудь сделает Hibernate”. Hibernate, конечно, сделает. Но не то, что вы хотели. В результате FK может не установиться или будет установлен позже и неочевидно. Поэтому helper-методы addItem()/removeItem() в доменных сущностях — не прихоть преподавателя, а способ сделать поведение стабильным.
Ошибка №3: смешать в одном тесте cascade, orphanRemoval и другие утверждения.
Большой “универсальный” тест кажется удобным до первого падения. Потом вы смотрите на него и не понимаете: у вас сломался каскад, удаление, связь, или вообще продукт не сохранился? Mapping-тесты должны быть маленькими: один тест — один смысл. Это ускоряет отладку и делает тестовую базу понятной даже через месяц.
Ошибка №4: orphanRemoval проверяется только по размеру коллекции.
Убрать элемент из коллекции — это действие в памяти. orphanRemoval — это действие в базе. Между ними стоит flush. Если в тесте нет flush, вы не проверили orphanRemoval. Если в тесте нет проверки факта в БД (например, entityManager.find(...) == null), вы не проверили orphanRemoval. Вы проверили List.remove().
Ошибка №5: “каскад включим везде, чтобы меньше думать”.
Это очень человеческое желание: меньше писать кода, меньше репозиториев, больше магии. Но каскад — это про жизненный цикл. Каскадить ManyToOne на справочник (Product → Category) обычно плохо, потому что категории живут своей жизнью. Каскадить OneToMany от заказа к позициям обычно хорошо, потому что позиции — часть заказа. Тесты как раз помогают увидеть эту грань и зафиксировать её как контракт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ