JavaRush /Курсы /Spring Data JPA /Тестируем mapping и каскады

Тестируем mapping и каскады

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

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-тест должен пройти цикл saveflushclear → 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: ProductCategory и проверка FK

Начнём с самого понятного вида связи, потому что он отлично показывает смысл “маппинга как контракта”. В домене mini-shop категория — справочник, товар на неё ссылается. В таблицах это выглядит как foreign key product.category_idcategory.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). Мы проверили, что цепочка Orderitemsproduct реально существует после повторного чтения. Это как раз тот случай, когда “проверить только размер” слишком слабо: размер может быть правильным даже при частично сломанной связи, а вот конкретное поле продукта даёт нам уверенность, что всё связалось по-настоящему.

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: ProductStockItem

Связь 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 на справочник (ProductCategory) обычно плохо, потому что категории живут своей жизнью. Каскадить OneToMany от заказа к позициям обычно хорошо, потому что позиции — часть заказа. Тесты как раз помогают увидеть эту грань и зафиксировать её как контракт.

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