«1. Введение

В этой статье мы подробно обсудим интеграционные тесты с использованием Spring и способы их оптимизации.

Сначала мы кратко обсудим важность интеграционных тестов и их место в современном программном обеспечении, сосредоточив внимание на экосистеме Spring.

Позже мы рассмотрим несколько сценариев, сосредоточившись на веб-приложениях.

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

Прежде чем начать, важно помнить, что это статья, основанная на собственном опыте. Что-то из этого вам может подойти, что-то нет.

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

2. Интеграционные тесты

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

Чем больше мы упрощаем наш код, используя модули Spring (данные, безопасность, социальные сети…), тем больше потребность в интеграционных тестах. Это становится особенно верным, когда мы перемещаем части нашей инфраструктуры в классы @Configuration.

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

Интеграционные тесты помогают нам завоевать доверие, но за них приходится платить:

    Это медленная скорость выполнения, что означает более медленную сборку. Кроме того, интеграционные тесты подразумевают более широкий охват тестирования, что в большинстве случаев не идеально Имея это в виду, мы попытаемся найти некоторые решения для смягчения вышеупомянутых проблем.

3. Тестирование веб-приложений

Spring предоставляет несколько опций для тестирования веб-приложений, и большинство разработчиков Spring знакомы с ними. веб-приложения TestRestTemplate: может использоваться для указания на наше приложение, полезно для нереактивных веб-приложений, где фиктивные сервлеты нежелательны WebTestClient: инструмент тестирования реактивных веб-приложений, как с фиктивными запросами/ответами, так и с использованием реального сервера

Поскольку у нас уже есть статьи, посвященные этим темам, мы не будем тратить на них время.

    Не стесняйтесь смотреть, если хотите копнуть глубже.

4. Оптимизация времени выполнения

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

Однако по мере взросления нашего приложения и увеличения объема разработки время сборки неизбежно увеличивается. По мере увеличения времени сборки выполнение всех тестов каждый раз может стать непрактичным.

После этого, влияя на нашу петлю обратной связи и продвигаясь по пути лучших практик разработки.

Кроме того, интеграционные тесты по своей природе дороги. Запуск какой-либо персистентности, отправка запросов (даже если они никогда не покидают локальный хост) или выполнение некоторого ввода-вывода просто требует времени.

Крайне важно следить за временем сборки, включая выполнение тестов. И есть некоторые приемы, которые мы можем применить в Spring, чтобы сохранить его на низком уровне.

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

«Разумное использование профилей — как профили влияют на производительность Переосмысление @MockBean — как насмешки влияют на производительность Рефакторинг @MockBean — альтернативы для повышения производительности Тщательное обдумывание @DirtiesContext — полезной, но опасной аннотации и того, как ее не использовать Использование тестовые срезы — классный инструмент, который может помочь или помешать нам. Использование наследования классов — способ безопасной организации тестов. лучший способ получить прочную и быструю сборку

Давайте начнем!

    4.1. Разумное использование профилей

Профили — довольно удобный инструмент. А именно, простые теги, которые могут включать или отключать определенные области нашего приложения. Мы могли бы даже реализовать с ними флаги функций!

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

Создание контекстов приложения может быть быстрым с загрузочным приложением vanilla spring, в котором ничего нет. Добавьте ORM и несколько модулей, и время быстро увеличится до 7+ секунд.

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

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

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

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

4.2. Проблемы с @MockBean

    @MockBean — довольно мощный инструмент.

Когда нам нужна магия Spring, но мы хотим смоделировать определенный компонент, @MockBean очень удобен. Но это происходит по цене.

Каждый раз, когда @MockBean появляется в классе, кеш ApplicationContext помечается как грязный, поэтому бегун очистит кеш после выполнения тестового класса. Что снова добавляет дополнительную кучу секунд к нашей сборке.

Это спорный вопрос, но попытка использовать реальное приложение вместо насмешек для этого конкретного сценария может помочь. Конечно, здесь нет серебряной пули. Границы размываются, когда мы не позволяем себе имитировать зависимости.

Мы могли бы подумать: зачем нам упорствовать, если все, что мы хотим протестировать, — это наш уровень REST? Это справедливое замечание, и всегда есть компромисс.

Однако, учитывая несколько принципов, это можно превратить в преимущество, которое приведет к лучшему дизайну как тестов, так и нашего приложения, а также сократит время тестирования.

4.3. Рефакторинг @MockBean

В этом разделе мы попытаемся реорганизовать «медленный» тест с помощью @MockBean, чтобы он повторно использовал кэшированный ApplicationContext.

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

Если мы правильно протестировали наш сервис, этого подхода должно быть достаточно:

Однако мы хотим избежать использования @MockBean. Таким образом, мы закончим сохранение объекта (при условии, что это то, что делает служба).

Самым наивным подходом здесь было бы проверить побочный эффект: после отправки сообщения мой пользователь находится в моей БД, в нашем примере это будет использовать JDBC.

class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() {

    @Autowired
    lateinit var mvc: MockMvc
    
    @MockBean
    lateinit var userService: UserService

    @Test
    fun links() {
        mvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content("""{ "name":"jose" }"""))
          .andExpect(status().isCreated)
        
        verify(userService).save("jose")
    }
}

interface UserService {
    fun save(name: String)
}

Это, однако, нарушает границы тестирования:

«

«В этом конкретном примере мы нарушаем границы тестирования, потому что рассматриваем наше приложение как черный ящик HTTP для отправки пользователя, но позже мы утверждаем, используя детали реализации, что наш пользователь был сохранен в какой-то БД.

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    assertThat(
      JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"))
      .isOne()
}

Если мы запускаем наше приложение через HTTP, можем ли мы подтвердить результат через HTTP?

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

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    mvc.perform(get("/users/jose"))
      .andExpect(status().isOk)
}

Наш тест запустится быстрее (возможно, его выполнение может занять немного больше времени, но это должно окупиться) Кроме того, наш тест не знает о побочных эффектах, не связанных с границами HTTP, т.е. базами данных Наконец, наш тест ясно выражает намерение системы: если вы отправите сообщение, вы сможете получить пользователей

    Конечно, это не всегда может быть возможно по разным причинам:

У нас может не быть конечной точки «побочного эффекта»: здесь можно рассмотреть возможность создания «конечных точек тестирования». Сложность слишком высока, чтобы затронуть все приложение: здесь можно учитывать слайсы (мы поговорим о них позже)

    4.4. Тщательное размышление о @DirtiesContext

Иногда нам может понадобиться изменить ApplicationContext в наших тестах. Для этого сценария @DirtiesContext предоставляет именно эту функциональность.

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

Некоторые случаи неправильного использования @DirtiesContext включают сброс кеша приложения или сброс базы данных в памяти. Есть лучшие способы обработки этих сценариев в интеграционных тестах, и мы рассмотрим некоторые из них в следующих разделах.

4.5. Использование тестовых слайсов

Тестовые слайсы — это функция Spring Boot, представленная в версии 1.4. Идея довольно проста: Spring создаст сокращенный контекст приложения для определенного фрагмента вашего приложения.

Кроме того, фреймворк позаботится о минимальной настройке.

В Spring Boot есть разумное количество срезов, доступных из коробки, и мы также можем создать свои собственные:

@JsonTest: регистрирует соответствующие компоненты JSON @DataJpaTest: регистрирует компоненты JPA, включая доступные ORM @JdbcTest: Полезно для необработанных тестов JDBC, заботится об источнике данных и в базах данных в памяти без излишеств ORM @DataMongoTest: пытается предоставить настройку тестирования монго в памяти @WebMvcTest: фиктивный тестовый фрагмент MVC без остальной части приложения ¦ ( мы можем проверить источник, чтобы найти их все)

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

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

4.6. Использование наследования классов

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

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

Мы могли бы установить все тестовые требования там:

Spring runner — или предпочтительно правила, если нам нужны другие профили runners более поздние профили — в идеале наша начальная конфигурация совокупного тестового профиля — установка состояния наше приложение

    Давайте посмотрим на простой базовый класс, который отвечает за предыдущие пункты:

4.7. Управление состоянием

@SpringBootTest
@ActiveProfiles("test")
abstract class AbstractSpringIntegrationTest {

    @Rule
    @JvmField
    val springMethodRule = SpringMethodRule()

    companion object {
        @ClassRule
        @JvmField
        val SPRING_CLASS_RULE = SpringClassRule()
    }
}

Важно помнить, откуда берется «единица измерения» в модульном тесте. Проще говоря, это означает, что мы можем запускать один тест (или подмножество) в любой момент, получая согласованные результаты.

Следовательно, состояние должно быть чистым и известным до начала каждого теста.

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

«Эта идея применима точно так же и к интеграционным тестам. Нам нужно убедиться, что наше приложение имеет известное (и повторяемое) состояние, прежде чем начинать новый тест. Чем больше компонентов мы повторно используем для ускорения работы (контекст приложения, базы данных, очереди, файлы…), тем больше шансов получить загрязнение состояния.

Предполагая, что мы пошли ва-банк с наследованием классов, теперь у нас есть центральное место для управления состоянием.

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

В нашем примере мы предполагаем наличие нескольких репозиториев (из разных источников данных) и сервера Wiremock:

4.8. Рефакторинг в модульные тесты

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 8666)
@AutoConfigureMockMvc
abstract class AbstractSpringIntegrationTest {

    //... spring rules are configured here, skipped for clarity

    @Autowired
    protected lateinit var wireMockServer: WireMockServer

    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate

    @Autowired
    lateinit var repos: Set<MongoRepository<*, *>>

    @Autowired
    lateinit var cacheManager: CacheManager

    @Before
    fun resetState() {
        cleanAllDatabases()
        cleanAllCaches()
        resetWiremockStatus()
    }

    fun cleanAllDatabases() {
        JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2")
        jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1")
        repos.forEach { it.deleteAll() }
    }

    fun cleanAllCaches() {
        cacheManager.cacheNames
          .map { cacheManager.getCache(it) }
          .filterNotNull()
          .forEach { it.clear() }
    }

    fun resetWiremockStatus() {
        wireMockServer.resetAll()
        // set default requests if any
    }
}

Это, наверное, один из самых важных моментов. Мы будем снова и снова сталкиваться с некоторыми интеграционными тестами, которые на самом деле реализуют некоторую высокоуровневую политику нашего приложения.

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

Возможный шаблон для успешного выполнения этой задачи может быть следующим:

Определите интеграционные тесты, проверяющие несколько сценариев основной бизнес-логики. Дублируйте набор и реорганизуйте копию в модульные тесты — на этом этапе нам может понадобиться разбить производственный код, чтобы сделать его пригодным для тестирования Получить все тесты зеленого цвета Оставить образец счастливого пути, который достаточно замечателен в наборе интеграции — нам может потребоваться реорганизовать или объединить и изменить несколько Удалить оставшиеся интеграционные тесты

    Майкл Фезерс описывает множество методов для достижения этого и многого другого в книге «Эффективная работа с устаревшим кодом».

5. Резюме

В этой статье мы познакомились с интеграционными тестами с упором на Spring.

Во-первых, мы говорили о важности интеграционных тестов и о том, почему они особенно важны для приложений Spring.

После этого мы обобщили некоторые инструменты, которые могут пригодиться для определенных типов интеграционных тестов в веб-приложениях.

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

«