Третья статья в цикле и небольшое ответвление от основной серии — в этот раз я покажу как устроена и как работает библиотека интеграционного тестирования 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)


  1. Hixon10
    01.04.2019 12:54

    Алексей, спасибо за статью.

    Если позволите, хотел бы написать один комментарий про:

    Я хотел показать, что никакой таинственной магии в Spring нет, есть просто много «умных» и гибких внутренних мехнизмов, но зная их можно получить полный контроль на тестами и самим приложением.


    ИМХО, но вы как раз в статье и показали магию: «всего-то надо реализовать такой-то интерфейс, и добавить вот такую вот аннотацию с таким-то значением».

    Если взять в противовес условный go, то там мы бы явно (руками) создали нужные компоненты для теста, и всё явно бы запустили.

    Да, гошный подход — более многословный. Но и более явный для человека, кто знает язык программирования, но не конкретный фреймворк.


    1. alek_sys Автор
      01.04.2019 14:09

      Я скорее имел в виду, что библиотеки, вроде указанной, не делают ничего магического внутри.


      А "реализовать интерфейс" и "добавить аннотацию" это для меня знание инструмента (Спринга). Чтобы им эффективно пользоваться — да, его надо знать. Но зато можно получать преимущества в виде продуктивности после.


      В условном Го просто другой подход: вместо абстракций — повторения. Не лучше и не хуже — каждый выберет свой. В общем-то, в Спринг тоже никто не мешает игнорировать библиотеки тестирования — и хоть руками создавать контексты и делать все, что делает Spring Test, получив полный контроль над тестом. Просто потом захочется абстрагировать одну вещь, чтобы не повторять. Потому другую. А потом получится еще один фреймворк для тестирования.


  1. poxvuibr
    01.04.2019 13:29

    Вот этот механизм кеширования контекстов всё портит. Вернее портит всё тот факт, что этот механизм считает, что контекст нужно пересоздавать при изменении списка конфигураций.


    Я вот хочу запустить тесты для той же конфигурации, что и везде, но один бин заменить тестовой реализацией. Делаю я это через создание дополнительной конфигурации, в которой метод, возвращающий новый бин помечен аннотацией Primary. В результате спринг замечает, что список конфигураций тут другой и создаёт новый контекст и, соответственно, поднимает ещё одну базу на случайном порту, что существенно замедляется тесты. Как это красиво решить я чего-то не нашёл.


    Единственное, что пока приходит в голову это поднимать встроенную БД ещё до запуска тестов и прокидывать её в конфигурации спринга.


    1. alek_sys Автор
      01.04.2019 14:15

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

      В ваших рассуждениях есть логическая ловушка — если заменить один бин это уже не та же конфигурация. Конфигурация в Spring достаточно фундаментальная вещь, которая по-сути описывает приложение.


      Вариантов решения есть несколько, зависит от задачи:


      1. Создать базовый класс для тестов с одинаковой конфигурацией. Обычно, это означает тесты для одного и того же компонента (когда замоканы boundaries, об этом еще буду говорить в следующих статьях).
      2. Как уже отмечено — иметь одну базу для всех тестов, если пересоздавать контексты совершенно необходимо.
      3. Подумать над каким-то runtime созданием бинов, но это уже немного воевать с фреймворком.


      1. poxvuibr
        01.04.2019 14:39

        В ваших рассуждениях есть логическая ловушка — если заменить один бин это уже не та же конфигурация.

        Как не называй, суть не поменяется. Мне хочется, чтобы все тесты, требующие БД проходили на одном и том же экземпляре встроенного postgresql.


        Создать базовый класс для тестов с одинаковой конфигурацией.

        Это ничем не поможет. При необходимости незначительно изменить конфигурацию Spring всё равно будет поднимать БД ещё раз.


        Как уже отмечено — иметь одну базу для всех тестов

        Это формулировка задачи, а не вариант решения )). Интересно, как это красиво сделать спрингом, если база встроенная.


        Подумать над каким-то runtime созданием бинов

        Наверное можно, но жутко неохота, хотя подозреваю, что этим и кончится)). Помогла бы какая-нибудь аннотация в Junit наподобие BeforeAll, только чтобы она была BeforeSuite или как-то так, но её по моему нет.


        1. alek_sys Автор
          02.04.2019 09:38

          Помогла бы какая-нибудь аннотация в Junit наподобие BeforeAll

          Да, ее нет, но например TestContainers выкручиваются используя static поля.


  1. poxvuibr
    01.04.2019 14:37

    del