@Transactional нельзя протестировать с @DataJpaTest

Вопрос или проблема

Я застрял со своими модульными тестами вокруг метода @Transactional. Проблема в том, что я не могу протестировать механизм отката внутри @DataJpaTest.
Вот мой упрощенный пример приложения:

@RestController
public class MyController {

    private final MyRepository myRepository;
    private final MyService    myService;

    public MyController(MyRepository myRepository, MyService myService) {
        this.myRepository = myRepository;
        this.myService = myService;
    }

    @GetMapping
    @Transactional
    public String justForTest() {
        var myEntity = new MyEntity().setStatus("NEW");
        myRepository.save(myEntity);
        try {
            myService.throwsAnRuntimeException();
            // если сервис успешен
            myEntity.setStatus("SUCCESS");
        } catch (Exception e) {
            // если сервис завершился неудачно
            myEntity.setStatus("FAIL");
            throw e;
        } finally {
            myRepository.save(myEntity);
        }
        return "this is the end";
    }
}
@Data
@Entity
@Accessors(chain = true)
public class MyEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String status;
}
@Repository
public interface MyRepository extends JpaRepository<MyEntity, Long> {
}
@Service
public class MyService {
    public void throwsAnRuntimeException() {
        throw new RuntimeException("Откаты не работают!");
    }
}

Таким образом, когда я запускаю этот код привычным способом (с локальной базой данных PostgreSQL), он работает, как и ожидалось: когда я вызываю запрос на localhost:8080, ответ – 500, и таблица в моей базе данных полностью пуста, как и ожидалось, потому что, когда метод @Transactional завершает работу с RuntimeException, он делает rollback всей транзакции (даже если в финальном блоке есть repository.save()).
Но когда я пытаюсь протестировать этот код с помощью @DataJpaTest (база данных h2 в classpath для тестового окружения):

@DataJpaTest
class MyControllerTest {

    @Autowired
    private MyRepository repository;

    @Test
    void justForTest() {
        var controller = new MyController(repository, new MyService());
        assertThrows(RuntimeException.class, controller::justForTest);
        var afterTestEntities = repository.findAll();
        System.out.println(afterTestEntities.getFirst());
        assertEquals(0, afterTestEntities.size());
    }
}

Я получаю сбой теста, и мой отладочный вывод показывает мне мою сущность с состоянием неудачи MyEntity(id=1, status=FAIL). Но я предполагаю, что, как и в реальном запуске, у меня должно быть пустое хранилище из-за отката транзакции.

Таким образом, мой главный вопрос: как протестировать механизм @Transactional в модульных тестах?

Это происходит потому, что тесты @DataJpaTest по умолчанию являются транзакционными. Транзакция начинается перед каждым тестом и откатывается после его завершения. Очевидно, что вы не можете протестировать откат транзакции, находясь внутри транзакции. Вам нужно отключить распространение транзакций, используя аннотацию @Transactional в вашем тесте.

@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class MyNonTransactionalTests {

    // ...

}

Для получения дополнительной информации смотрите Справочник по Spring Boot.

Также, если контроллер не является Spring Bean, @Transactional не будет работать для него. Поэтому вам нужно будет добавить его в @Import и внедрить в ваш тест с помощью @Autowired.

@DataJpaTest
@Import({MyController.class})
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class MyControllerTest {

    @Autowired
    private MyController myController;
 
    // ... 
}

Основная проблема заключается в том, что @DataJpaTest не обрабатывает аннотацию @Transactional на вашем методе контроллера, потому что контроллер не управляется Spring в вашей тестовой настройке. Попробуйте добавить конфигурацию теста транзакций:

@Configuration
@EnableTransactionManagement
public class TransactionalTestConfig {
    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

Затем в вашем классе теста:


@DataJpaTest
@Import({TransactionalTestConfig.class, MyService.class})
class MyControllerTest {

 @Autowired
    private MyRepository repository;
    
    @Autowired
    private PlatformTransactionManager transactionManager;
    
    @Autowired
    private MyService myService;

   @Test
    void testTransactionalRollback() {
        // Создать шаблон транзакции
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        
        // Выполнить тест внутри транзакции
        assertThrows(RuntimeException.class, () -> {
            transactionTemplate.execute(status -> {
                var controller = new MyController(repository, myService);
                controller.justForTest();
                return null;
            });
        });

        // Проверить откат
        var afterTestEntities = repository.findAll();
        assertEquals(0, afterTestEntities.size());
    }

}

в вашем свойстве:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.springframework.transaction=TRACE

.

Ответ или решение

Проблема с тестированием аннотации @Transactional в рамках @DataJpaTest

При тестировании методов, помеченных аннотацией @Transactional, с использованием аннотации @DataJpaTest может возникнуть закономерная проблема. Ваша цель — подтвердить механизм отката транзакции, который работает корректно в реальной среде, но не проявляет ожидаемого поведения в тестовом окружении. Давайте рассмотрим детали этой проблемы и возможные решения.

Проблема

Как вы указали, ваш контроллер содержит @Transactional метод, который обрабатывает исключения и, в случае таких событий как RuntimeException, является предопределенным для выполнения отката транзакции. В реальных условиях, при вызове этого метода через HTTP-запрос, поведение корректно, и все изменения в базе данных откатываются. Однако, тестирование этого поведения с @DataJpaTest не дает ожидаемого результата — изменения в базе данных сохраняются.

Это происходит потому, что @DataJpaTest ведет себя иначе, чем вы могли бы ожидать: он управляет транзакциями самостоятельно, используя механизм, который автоматически откатывает транзакции после выполнения каждого теста. Таким образом, вам не удается правильно протестировать логику отката, расположенную внутри @Transactional метода вашего контроллера.

Решение

1. Деактивируйте аннотацию @Transactional в тестах

Первый шаг, который следует предпринять, — это провести тест вне управления транзакциями, заданного @DataJpaTest. Вы можете сделать это, добавив аннотацию @Transactional(propagation = Propagation.NOT_SUPPORTED) к вашему классу тестов:

@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class MyControllerTest {

    @Autowired
    private MyRepository repository;

    @Test
    void justForTest() {
        var controller = new MyController(repository, new MyService());
        assertThrows(RuntimeException.class, controller::justForTest);

        var afterTestEntities = repository.findAll();
        assertEquals(0, afterTestEntities.size());
    }
}

2. Импортируйте ваш контроллер

Поскольку контроллер не является бином Spring в контексте тестирования, необходимо проверить и убедиться в его импорте. Используйте аннотацию @Import для этой цели:

@DataJpaTest
@Import(MyController.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class MyControllerTest {

    @Autowired
    private MyController myController;

    // ...
}

3. Используйте конфигурацию теста с поддержкой транзакций

Если вы хотите иметь возможность тестировать механизм отката как таковой, вы можете создать специальную конфигурацию для тестирования:

@Configuration
@EnableTransactionManagement
public class TransactionalTestConfig {
    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

Затем в вашем тесте подключите эту конфигурацию:

@DataJpaTest
@Import({TransactionalTestConfig.class, MyService.class})
class MyControllerTest {

    @Autowired
    private MyRepository repository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @Test
    void testTransactionalRollback() {
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);

        assertThrows(RuntimeException.class, () -> {
            transactionTemplate.execute(status -> {
                myController.justForTest();
                return null;
            });
        });

        var afterTestEntities = repository.findAll();
        assertEquals(0, afterTestEntities.size());
    }
}

4. Свойства для отображения SQL

Для улучшения отладки в вашем файле настроек добавьте следующие параметры:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.springframework.transaction=TRACE

Эти параметры помогут вам видеть выполняемые запросы, что делает процесс тестирования более прозрачным.

Заключение

Тестирование методов с аннотацией @Transactional в рамках @DataJpaTest может оказаться непростой задачей, особенно если вы не учитываете особенности управления транзакциями, встроенные в Spring-окружение. Данное руководство предоставляет решения, позволяющие преодолеть эту сложность, и обеспечит надежное тестирование вашего бизнес-логики применительно к управлению транзакциями. Применив предложенные стратегии, вы сможете корректно отладить механизм отката и достичь высокой надежности ваших тестов.

Оцените материал
Добавить комментарий

Капча загружается...