Тайна JdbcTest в JUnit 5 | OTUS
Прямо сейчас идет открытый вебинар «Роботы и триггеры CRM. Автоматизация воронок» . Присоединяйтесь!

Тайна JdbcTest в JUnit 5

Spring Boot предоставляет широкий набор инструментов для интеграционного тестирования приложения с использованием IoC-контейнера. Применяя те или иные аннотации, мы можем варьировать, какие наши или спринговые компоненты окажутся в контексте и каким образом они будут сконфигурированы.

Например, есть аннотация @SpringBootTest, которая, если мы специально не ограничим область ее действия, может поднять контекст целиком. Также существуют более узкоспециальные аннотации, отвечающие за создание только определенных компонентов, обычно относящихся к тому или иному слою приложения. Одной из таких аннотаций является @JdbcTest и именно о том, как она работает, пойдет речь далее.

Особенности @JdbcTest

Про данную аннотацию известно следующее: — добавляет в контекст компоненты для работы с БД: - DataSource; - JdbcTemplate; - NamedParameterJdbcTemplate; ... — в начале каждого теста открывает транзакцию; — и откатывает в конце; — если над тестовым классом или методом явно прописать @Transactional(propagation = Propagation.NOT_SUPPORTED), то транзакция перед тестом открываться не будет.

В текущей заметке мы постараемся выяснить, за счет чего работают последние три пункта списка.

Общие принципы работы спринговых аннотаций

Для тестирования мы будем использовать JUnit пятой версии. И вначале попробуем разобраться, как получается, что фреймворк для тестирования, который по идее ничего не знает ни о каком спринге, начинает взаимодействовать с его аннотациями, внедрять что-то в тестовые классы и т. д. Т. е. откуда в тестах берется спринг.

Мы не зря выбрали для экспериментов именно пятый JUnit (и Spring Boot 2.3.1). В прошлых версиях, чтобы аннотации от спринг возымели эффект, надо было явно указать @RunWith(SpringRunner.class). Это уже могло навести на некоторые мысли о том, как все работает. В современной версии достаточно указать только @SpringBootTest или @JdbcTest, и все заведется магическим образом, само.

Или все же не магически? Давайте откроем, любой проект, использующий spring-boot-starter-test в среде разработки "IntelliJ IDEA" (достаточно Community версии), найдем аннотацию @JdbcTest и посмотрим на ее код. Также желательно загрузить исходные коды, когда IDEA это предложит (Download Sources), если они не были загружены до этого.

Итак. Открыв код аннотации мы видим примерно следующее:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(JdbcTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(JdbcTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureJdbc
@AutoConfigureTestDatabase
@ImportAutoConfiguration
public @interface JdbcTest {
    // И другой код...
}

Тут много всего интересного, но на данный момент нам нужно обратить внимание только на одну строку:

@ExtendWith(SpringExtension.class)

Это как раз то, что позволяет подключить спринг в тесты. @ExtendWith — аннотация от JUnit 5, SpringExtension — класс спринга, который реализует несколько интерфейсов тестового фреймворка. Давайте заглянем внутрь:

public class SpringExtension implements BeforeAllCallback, 
    AfterAllCallback, TestInstancePostProcessor,
    BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback,
    AfterTestExecutionCallback, ParameterResolver {

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        getTestContextManager(context).beforeTestClass();
    }

    // Другие методы коллбеков от интерфесов JUnit...

    private static TestContextManager getTestContextManager(ExtensionContext context) {        
        // Код...
    }
}

Мы видим несколько методов, которые унаследованы от интерфесов коллбеков JUnit, каждый из которых с помощью getTestContextManager получает объект типа TestContextManager и вызывает тот или иной его метод, чье название говорит само за себя.

Если взглянуть на конструкторы TestContextManager:

public class TestContextManager {

    public TestContextManager(Class<?> testClass) {
        this(BootstrapUtils.resolveTestContextBootstrapper(
            BootstrapUtils.createBootstrapContext(testClass))
        );
    }

    public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
        this.testContext = testContextBootstrapper.buildTestContext();
        registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
    }
}

то в BootstrapUtils.resolveTestContextBootstrapper(...), мы увидим знакомое слово, которое нам уже встречалось. А именно — TestContextBootstrapper. И действительно. Мы его видели над аннотацией @JdbcTest:

// Код...
@BootstrapWith(JdbcTestContextBootstrapper.class)
// Еще код...
public @interface JdbcTest {
    // И еще код...
}

Собственно, это как раз тот класс, который создаст для теста контекст спринга. Само создание происходит в строке this.testContext = testContextBootstrapper.buildTestContext(); второго конструктора класса TestContextManager.

Т. о. мы разобрались, откуда в тестах берется контекст спринг. Предлагаю зафиксировать все что нам уже известно: — JUnit находит над классом аннотацию @ExtendWith; — в классе, что указан в качестве аргумента аннотации (в нашем случае это SpringExtension.class), фреймворк вызывает методы-коллбеки своих интерфейсов, который данный класс реализует; — в коллбеках создается экземпляр класса TestContextManager; — в его конструкторе происходит вычисление класса, что будет создавать контекст спринга: - в BootstrapUtils.resolveTestContextBootstrapper(...) определяется, какая аннотация висит над тестовым классом (у нас @JdbcTest); - над ней ищется @BootstrapWith и берется класс, указанный в качестве аргумента; — с помощью найденного класса (в нашем случае JdbcTestContextBootstrapper) создается контекст.

А что с транзакциями?

Теперь пришло время выяснить, почему перед тестом открываются транзакции. Тут все просто. Над @JdbcTest висит @Transactional. Вот он:

// Код...
@Transactional
// Еще код...
public @interface JdbcTest {
    // И еще код...
}

Если не учитывать, что @Transactional работает только для объектов в контексте (а класс с тестом там не лежит), то создание транзакции перед каждым методом класса с @Transactional является для спринга вполне штатной ситуацией. А вот то, что она откатывается — нет. Займемся выяснением причин такого поведения.

Если вернуться к классу TestContextManager, можно увидеть в нем следующий код:

public class TestContextManager {

    // Код...

    public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
        // Код...
        registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
    }

    // Код...

    public void beforeTestMethod(Object testInstance, Method testMethod) throws Exception {
        // Код...
        for (TestExecutionListener testExecutionListener : getTestExecutionListeners()) {
            try {
                testExecutionListener.beforeTestMethod(getTestContext());
            }
            catch (Throwable ex) {
                // Код...
            }
        }
    }

    // Код...

    public void afterTestMethod(Object testInstance, Method testMethod, @Nullable Throwable exception)
            throws Exception {
        // Код...
        for (TestExecutionListener testExecutionListener : getReversedTestExecutionListeners()) {
            try {
                testExecutionListener.afterTestMethod(getTestContext());
            }
            catch (Throwable ex) {
                // Код...
            }
        }
        // Код...
    }
}

Т. е. сначала регистрируются какие-то TestExecutionListener-ы, а потом прогоняются в обработчике коллбека для JUnit. Выше показан частичный код методов, которые будут вызваться перед и после каждого теста соответственно.

TestExecutionListener — это интерфейс и одной из его реализаций является TransactionalTestExecutionListener. Вот интересный кусочек кода этого класса:

public class TransactionalTestExecutionListener extends AbstractTestExecutionListener {
    @Override
    public void beforeTestMethod(final TestContext testContext) throws Exception {
        // Код...
        if (transactionAttribute != null) {
            if (transactionAttribute.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NOT_SUPPORTED) {
                return;
            }
        }
        // Код...
        if (tm != null) {
            txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
            runBeforeTransactionMethods(testContext);
            txContext.startTransaction();
            TransactionContextHolder.setCurrentTransactionContext(txContext);
        }
    }
}

Сразу видно, что если у аннотации @Transactional, аргумент propagation будет равен NOT_SUPPORTED, то транзакция даже не будет создана. А вот вот вызов метода isRollback при создании TransactionContext очень похож на то место, где наше расследование завершится. Этот метод определяет, нужно ли откатывать транзакцию после завершения метода теста.

public class TransactionalTestExecutionListener extends AbstractTestExecutionListener {
    // Код...
    protected final boolean isRollback(TestContext testContext) throws Exception {

        boolean rollback = isDefaultRollback(testContext);

        Rollback rollbackAnnotation = AnnotatedElementUtils
            .findMergedAnnotation(testContext.getTestMethod(), Rollback.class);

        if (rollbackAnnotation != null) {
            boolean rollbackOverride = rollbackAnnotation.value();
            // Код...
            rollback = rollbackOverride;
        }
        else {
            // Код...
        }
        return rollback;
    }
    // Код...
}

Ага. Если над тестовым методом найдена аннотация @Rollback, то будет использоваться значение ее аргумента value (еще один способ изменить поведение @Transactional над тестом). Если же такая аннотация не найдена, то ориентируемся на результат isDefaultRollback(testContext).

public class TransactionalTestExecutionListener extends AbstractTestExecutionListener {
    // Код...
    protected final boolean isDefaultRollback(TestContext testContext) throws Exception {
        Class<?> testClass = testContext.getTestClass();
        Rollback rollback = AnnotatedElementUtils.findMergedAnnotation(testClass, Rollback.class);
        boolean rollbackPresent = (rollback != null);

        if (rollbackPresent) {
            boolean defaultRollback = rollback.value();
            // Код...
            return defaultRollback;
        }

        // else
        return true;
    }
    // Код...

Тут примерно то же самое. Только @Rollback ищется над классом теста. Если аннотация не найдена, возвращается true. Т. е. откатываем транзакцию.

Подытожим:

  • когда контекст создан, TestContextManager регистрирует набор TestExecutionListener-ов;
  • при вызове метода-коллбека у каждого лисенера вызывается метод, соответствующий текущему событию жизненного цикла теста;
  • один из таких лисенеров — это TransactionalTestExecutionListener;
  • у которого перед каждым тестом вызывается метод beforeTestMethod, где создается транзакция;
  • если над тестовым классом или методом теста висит аннотация @Rollback, то будет ли откатываться транзакция после теста, зависит от значения, что передали в аргумент аннотации;
  • если же данная аннотация не найдена, то транзакция по умолчанию будет откачена;
  • и произойдет это при ее завершении внутри метода afterTestMethod класса TransactionalTestExecutionListener, что будет вызван для события завершения теста.

Вот и все)

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто