Третья статья в цикле и небольшое ответвление от основной серии — в этот раз я покажу как устроена и как работает библиотека интеграционного тестирования Spring, что происходит при запуске теста и как можно тонко настраивать приложение и его окружения для теста.
Написать эту статью меня подтолкнул комментарий Hixon10 про то, как использовать реальную базу, например Postgres, в интеграционном тесте. Автор комментария предложил использовать удобную all-included библиотеку embedded-database-spring-test. И я уже было добавил абзац и пример использования в коде, но потом задумался. Конечно, взять готовую библиотеку это правильно и хорошо, но если цель все таки понять как писать тесты для Spring приложения, то полезнее будет показать, как самому реализовать самому тот же функционал. Во-первых, это отличный повод поговорить про то, что под капотом у Spring Test. А во-вторых, я считаю, что нельзя полагаться на сторонние библиотеки, если не понимаешь как они устроены внутри, это ведет только к укреплению мифа о "магии" технологии.
В этот раз пользовательской фичи не будет, но будет проблема, которую нужно решить — я хочу запустить реальную базу данных на случайном порту и подключить приложение к этой временной базе автоматически, а после тестов базу остановить и удалить.
Сначала, как уже повелось, немного теории. Людям не слишком знакомым с понятиями бин, контекст, конфигурация я рекомендую освежить знания, например, по моей статье Обратная сторона Spring / Хабр.
Spring Test
Spring Test это одна из библиотек, входящих в Spring Framework, по сути все, что описано в разделе документации про интеграционное тестирование как раз о ней. Четыре главных задачи, которые решает библиотека это:
- Управлять Spring IoC контейнерами и их кэшированием между тестами
- Предоставить внедрение зависимостей для тестовых классов
- Предоставить управление транзакциями, подходящее для интеграционных тестов
- Предоставить набор базовых классов чтобы помочь разработчику писать интеграционные тесты
Я крайне рекомендую прочитать официальную документацию, там написано много всего полезного и интересного. Здесь же я приведу скорее краткую выжимку и несколько практических советов, которые полезно держать в уме.
Жизненный цикл теста
Жизненный цикл теста выглядит так:
- Расширение для тестового фреймворка (
SpringRunner
для JUnit 4 иSpringExtension
для JUnit 5) вызывает Test Context Bootstrapper - Boostrapper создает
TestContext
— основной класс, который хранит текущее состояние теста и приложения TestContext
настраивает разные хуки (вроде запуска транзакций до теста и отката после), инжектит зависимости в тестовые классы (все@Autowired
поля на тестовых классах) и занимается созданием контекстов- Контекст создается используя Context Loader — тот берет базовую конфигурацию приложения и сливает ее с тестовой конфигурацией (перекрытые свойства, профили, бины, инициализаторы и т.п.)
- Контекст кешируется используя составной ключ, который полностью описывает приложение — набор бинов, свойств и т.п.
- Тест запускается
Всю грязную работу по управлению тестами делает, собственно,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
Ну и разумеется, любое изменение конфигурации тоже создаст новый контекст.
Запускаем базу
Итак, теперь со всем этим знанием мы попробуем взлететь понять как и где можно запускать базу. Единственного правильного ответа тут нет, зависит от требований, но можно подумать над двумя вариантами:
- Запускать один раз до всех тестов в классе.
- Запускать случайный инстанс и отдельную базу на каждый закешированный контекст (потенциально более чем один класс).
В зависимости от требований, можно выбрать любую опицю. Если в моем случае, 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 строчек кода, то наверное тащить зависимость в проект, особенно не понимая реализацию, это лишнее.