Как стать автором
Обновить

TDD приложений на Spring Boot: тонкая настройка тестов и работа с контекстом

Время на прочтение6 мин
Количество просмотров29K

Третья статья в цикле и небольшое ответвление от основной серии — в этот раз я покажу как устроена и как работает библиотека интеграционного тестирования Spring, что происходит при запуске теста и как можно тонко настраивать приложение и его окружения для теста.


Написать эту статью меня подтолкнул комментарий Hixon10 про то, как использовать реальную базу, например Postgres, в интеграционном тесте. Автор комментария предложил использовать удобную all-included библиотеку embedded-database-spring-test. И я уже было добавил абзац и пример использования в коде, но потом задумался. Конечно, взять готовую библиотеку это правильно и хорошо, но если цель все таки понять как писать тесты для Spring приложения, то полезнее будет показать, как самому реализовать самому тот же функционал. Во-первых, это отличный повод поговорить про то, что под капотом у Spring Test. А во-вторых, я считаю, что нельзя полагаться на сторонние библиотеки, если не понимаешь как они устроены внутри, это ведет только к укреплению мифа о "магии" технологии.


В этот раз пользовательской фичи не будет, но будет проблема, которую нужно решить — я хочу запустить реальную базу данных на случайном порту и подключить приложение к этой временной базе автоматически, а после тестов базу остановить и удалить.


Сначала, как уже повелось, немного теории. Людям не слишком знакомым с понятиями бин, контекст, конфигурация я рекомендую освежить знания, например, по моей статье Обратная сторона Spring / Хабр.


Spring Test


Spring Test это одна из библиотек, входящих в Spring Framework, по сути все, что описано в разделе документации про интеграционное тестирование как раз о ней. Четыре главных задачи, которые решает библиотека это:


  • Управлять Spring IoC контейнерами и их кэшированием между тестами
  • Предоставить внедрение зависимостей для тестовых классов
  • Предоставить управление транзакциями, подходящее для интеграционных тестов
  • Предоставить набор базовых классов чтобы помочь разработчику писать интеграционные тесты

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


Жизненный цикл теста



Жизненный цикл теста выглядит так:


  1. Расширение для тестового фреймворка (SpringRunner для JUnit 4 и SpringExtension для JUnit 5) вызывает Test Context Bootstrapper
  2. Boostrapper создает TestContext — основной класс, который хранит текущее состояние теста и приложения
  3. TestContext настраивает разные хуки (вроде запуска транзакций до теста и отката после), инжектит зависимости в тестовые классы (все @Autowired поля на тестовых классах) и занимается созданием контекстов
  4. Контекст создается используя Context Loader — тот берет базовую конфигурацию приложения и сливает ее с тестовой конфигурацией (перекрытые свойства, профили, бины, инициализаторы и т.п.)
  5. Контекст кешируется используя составной ключ, который полностью описывает приложение — набор бинов, свойств и т.п.
  6. Тест запускается

Всю грязную работу по управлению тестами делает, собственно, spring-test, а Spring Boot Test в свою очередь добавляет несколько вспомогательных классов, вроде уже знакомых @DataJpaTest и @SpringBootTest, полезные утилиты, вроде TestPropertyValues чтобы динамически менять свойства контекста. Так же он позволяет запускать приложение как реальный web-server, или как mock-окружение (без доступа по HTTP), удобно мокать компоненты системы используя @MockBean и т.п.

Кеширование контекста


Пожалуй, одна из очень непонятных тем в интеграционном тестировании, которая вызывает много вопросов и заблуждений — это кеширование контекста (см. пункт 5 выше) между тестами и его влияние на скорость выполнения тестов. Частый комментарий, который я слышу, это то, что интеграционные тесты "медленные" и "запускают приложение на каждый тест". Так вот, они действительно запускают — однако не на каждый тест. Каждый контекст (т.е. инстанс приложения) будет переиспользован по максимуму, т.е. если 10 тестов используют одинаковую конфигурацию приложения — то приложение запустится один раз на все 10 тестов. Что же значит "одинаковая конфигурация" приложения? Для Spring Test это значит что не изменился набор бинов, классов конфигураций, профилей, свойств и т.п. На практике это означает, что например эти два теста будут использовать один и тот же контекст:


@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource("foo=bar")
class FirstTest {

}

@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource("foo=bar")
class SecondTest {

}

Количество контекстов в кэше ограничено 32-мя — дальше по принципу LRSU один из них будет удален из кэша.

Что же может помешать Spring Test переиспользовать контекст из кэша и создать новый?


@DirtiesContext
Самый простой вариант — если тест помечен это аннотаций, кэшироваться контекст не будет. Это может быть полезно, если тест меняет состояние приложение и хочется его "сбросить".


@MockBean
Очень неочевидный вариант, я даже вынес его отдельно — @MockBean заменяет реальный бин в контексте на мок, который можно тестировать через Mockito (в следующих статьях я еще покажу как это использовать). Ключевой момент — эта аннотация меняет набор бинов в приложении и заставляет Spring Test создать новый контекст. Если взять предыдущий пример, то например здесь уже будут созданы два контекста:


@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource("foo=bar")
class FirstTest {

}

@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource("foo=bar")
class SecondTest {
     @MockBean
   CakeFinder cakeFinderMock;
}

@TestPropertySource
Любое изменение свойств автоматически меняет ключ кэша и создается новый контекст.


@ActiveProfiles
Изменение активный профилей тоже повлияет на кэш.


@ContextConfiguration
Ну и разумеется, любое изменение конфигурации тоже создаст новый контекст.


Запускаем базу


Итак, теперь со всем этим знанием мы попробуем взлететь понять как и где можно запускать базу. Единственного правильного ответа тут нет, зависит от требований, но можно подумать над двумя вариантами:


  1. Запускать один раз до всех тестов в классе.
  2. Запускать случайный инстанс и отдельную базу на каждый закешированный контекст (потенциально более чем один класс).

В зависимости от требований, можно выбрать любую опицю. Если в моем случае, Postgres стартует относительно быстро и второй вариант выглядит подходящим, то для чего-то более тяжелого может подойти и первый.


Первый вариант не завязан на Spring, а скорее на тестовый фреймворк. Например, можно сделать свой Extension для JUnit 5.

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


За выполнение действий с контекстом до запуска в Spring отвечает интерфейс ApplicationContextInitializer.


ApplicationContextInitializer


У интерфейса всего один метод initialize, который выполняется до "запуска" контекста (т.е. до вызова метода refresh ) и позволяет внести изменения контекст — добавить бины, свойства.


В моем случае класс выглядит так:


public class EmbeddedPostgresInitializer
        implements ApplicationContextInitializer<GenericApplicationContext> {

    @Override
    public void initialize(GenericApplicationContext applicationContext) {
        EmbeddedPostgres postgres = new EmbeddedPostgres();
        try {
            String url = postgres.start();
            TestPropertyValues values = TestPropertyValues.of(
                    "spring.test.database.replace=none",
                    "spring.datasource.url=" + url,
                    "spring.datasource.driver-class-name=org.postgresql.Driver",
                    "spring.jpa.hibernate.ddl-auto=create");

            values.applyTo(applicationContext);

            applicationContext.registerBean(EmbeddedPostgres.class, () -> postgres,
                    beanDefinition -> beanDefinition.setDestroyMethodName("stop"));
        }
        catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

Первое что здесь происходит — запускается embedded Postgres, из библиотеки yandex-qatools/postgresql-embedded. Затем, создается набор свойств — JDBC URL для свежезапущенной базы, тип драйвера, и поведение Hibernate для схемы (автоматически создавать). Одна неочевидная вещь это только spring.test.database.replace=none — этим мы говорим DataJpaTest-у, что не надо пытаться подключится к встраиваемой БД, типа H2 и не надо подменять DataSource бин (так это работает).


И еще важный момент это application.registerBean(…). Вообще, этот бин можно, конечно, и не регистрировать — если в приложении его никто не использует, он не особо нужен. Регистрация нужна только чтобы указать destroy method, который Spring вызовет при уничтожении контекста, и в моем случае этот метод вызовет postgres.stop() и остановит базу.


В общем-то и все, магия закончилась, если какая-то и была. Теперь я зарегистрирую этот инициализатор в тестовом контексте:


@DataJpaTest
@ContextConfiguration(initializers = EmbeddedPostgresInitializer.class)
...

Или даже для удобства можно создать свою аннотацию, потому что все мы любим аннотации!


@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@DataJpaTest
@ContextConfiguration(initializers = EmbeddedPostgresInitializer.class)
public @interface EmbeddedPostgresTest {
}

Теперь любой тест, аннотированный @EmbeddedPostgrestTest запустит базу на случайном порту и со случайным именем, настроит Spring на подключение к этой базе и в конце теста остановит ее.


@EmbeddedPostgresTest
class JpaCakeFinderTestWithEmbeddedPostgres {
...
}

Заключение


Я хотел показать, что никакой таинственной магии в Spring нет, есть просто много "умных" и гибких внутренних мехнизмов, но зная их можно получить полный контроль на тестами и самим приложением. Вообще, в боевых проектах я не мотивирую всех писать свои методы и классы для настройки интеграционного окружения для тестов, если есть готовое решение то можно взять и его. Хотя если весь метод это 5 строчек кода, то наверное тащить зависимость в проект, особенно не понимая реализацию, это лишнее.


Ссылки на остальные статьи серии


Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии7

Публикации

Истории

Работа

Java разработчик
366 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань