Почему findById() возвращает прокси?

В приложении есть сущность Student. Я тестирую метод update() в её контроллере и сервисе. Тест метода update() заключается в следующем: сначала я обновляю студента в базе, а потом вытаскиваю его из базы и сравниваю с эталонным образцом. В тесте сервиса всё срабатывает отлично, а в тесте контроллера я получаю ошибку. Проследив путь этой ошибки, я выяснил, что метод JpaRepository#findById возвращает прокси, а не реального студента, и когда я пытаюсь сравнить это прокси с эталонным объектом, я получаю ошибку.

Вот часть ошибки:

java.lang.AssertionError:
...
ru.zhenyria.restaurants.model.Student$HibernateProxy$KBik0QLG can't be compared to
ru.zhenyria.restaurants.model.Student as Student does not declare all Student$HibernateProxy$KBik0QLG 
fields, it lacks these: [$$_hibernate_interceptor]

Возможную причину такого странного поведения findById() я нашёл вот здесь.

Код и мои попытки решения под катом.


Я покажу вам только часть кода, потому что ошибка локализована и не имеет отношение к самой сущности или контроллеру.

CRUD-репозиторий:

@Transactional(readOnly = true)
public interface CrudStudentRepository extends JpaRepository<Student, Integer> {
}

Репозиторий:

@Repository
public class StudentRepository {

    private final CrudStudentRepository repository;

    public StudentRepository(CrudStudentRepository repository) {
        this.repository = repository;
    }

    public Student get(int id) {
        return repository.findById(id).orElse(null);
    }
    
    // другие методы

Сервис:

@Service
public class StudentService {

    private final StudentRepository repository;

    public StudentService(StudentRepository repository) {
        this.repository = repository;
    }

    public Student get(int id) {
        return repository.get(id);
    }

    @Transactional
    public void update(StudentTo studentTo) {
        Student student = // логика по получению Student из StudentTo
        repository.save(student);
    }

    // другие методы

Тест сервиса:

@SpringJUnitConfig(locations = {
        "classpath:spring/spring-app.xml",
        "classpath:spring/spring-db.xml"
})
@Sql(scripts = "classpath:db/populateDB.sql", config = @SqlConfig(encoding = "UTF-8"))
@ExtendWith(TimingExtension.class)
public class StudentServiceTest {

    @Autowired
    private StudentService service;

    @Test
    void update() {
        StudentTo student = getUpdatedTo(); // метод getUpdatedTo() возвращает TO обновлённого студента

        service.update(student);
        assertThat(service.get(updated.getId())).isEqualTo(getUpdated());
    }

    // другие тесты

Тест контроллера:

@SpringJUnitWebConfig(locations = {
        "classpath:spring/spring-app.xml",
        "classpath:spring/spring-mvc.xml",
        "classpath:spring/spring-db.xml"
})
@Transactional
public class StudentControllerTest {
private static final String REST_URL = StudentController.REST_URL + "/";

@Autowired
private StudentService service;

@Test
void update() throws Exception {
    StudentTo updated = getUpdatedTo();
    perform(MockMvcRequestBuilders.put(REST_URL + "/" + updated.getId()))
            .contentType(MediaType.APPLICATION_JSON)
            .content(jsonWithPassword(updated, updated.getPassword())))
            .andDo(print())
            .andExpect(status().isNoContent());


    assertThat(service.get(updated.getId())).isEqualTo(getUpdated());
}

// другие тесты

Насколько вы видите, общий принцип тестирования в StudentServiceTest и StudentControllerTest схож: сначала обновляем студента в базе, а потом вытаскиваем из базы и сравниваем с эталоном вот в этой строке: STUDENT_MATCHER.assertMatch(service.get(updated.getId()), getUpdated()), только StudentControllerTest получает прокси при попытки вытаскивания студента из базы, из-за чего валится.

Вероятно, проблема возникла из-за того, что StudentControllerTest аннотирован @Transactional, но это нужно для отката изменений в базе. Я пробовал сделать вот так:

@Transactional(propagation = Propagation.NEVER)
@Test
void update() throws Exception {
    Student updated = getUpdated();
    perform(MockMvcRequestBuilders.put(REST_URL + "/" + updated.getId()))
            .contentType(MediaType.APPLICATION_JSON)
            .content(jsonWithPassword(updated, updated.getPassword())))
            .andDo(print())
            .andExpect(status().isNoContent());

    STUDENT_MATCHER.assertMatch(service.get(updated.getId()), getUpdated());
}

Теперь из базы приходит не прокси, а реальный объект. Но из-за этого стали валиться другие тесты, так как изменения в базе перестали откатываться.

Таким образом у меня возникло несколько вопросов:

  1. Как в моём случае избежать того, чтобы findById возвращал прокси?
  2. Или как мне сделать так, чтобы сохранения в базе откатывались, но при было propagation=Propagation.NEVER?
  3. Есть ли ещё какой-то вариант решить мою проблему?

Обратите внимание, что в принципе сам контроллер работает полностью адекватно, проверено через Postman. Проблема исключительно в тесте. Причём проблема не возникает в тесте сервиса, из чего я делаю вывод, что проблема может быть отчасти из-за того, что над классом теста контроллера установлена аннотация @Transactional, но как иначе?


Ответы (1 шт):

Автор решения: Zhenyria

Ответ меня удивил.

Я нашёл его вот здесь.

Суть следующая. У нас есть два метода: getOne(), который возвращает прокси, и findById(), который возвращает полностью проинициализированный объект.

Но если мы вызываем оба эти метода в одной транзакции, то их поведение может измениться. Например, если мы в одной транзакции сначала вызываем findById(), то повторно вызванный в той же транзакции getOne(), при помощи которого мы пытаемся получить тот же объект, будет возвращать полностью проинициализированный объект, а не прокси (если я не ошибаюсь, на время транзакции объект хранится в памяти).

Такое же поведение происходит и в обратную сторону:

@Transactional
public void test(int id) {
    Person lazyPerson = getProxy(id);
    Person initializedPerson = getInitialized(id);
}

public Person getProxy(int id) {
    return dao.getOne(id);
}

public Person getInitialized(int id) {
    return dao.findById(id).orElse(null);
}

В данном случае переменная initializedPerson в методе test() будет проинициализирована не реальным объектом, а всего лишь прокси-обёрткой, в которой есть только id. Будьте внимательны с этим. Я столкнулся с таким поведением в ходе тестов, которые открывали свою транзакцию, и для решения проблемы использовал обычный метод Hibernate.initialize().

→ Ссылка