Оптимизация тестов интеграции в Spring

Оптимизация весенних интеграционных тестов

1. Вступление

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

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

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

Next, we’ll discuss some strategies to improve testing speed, узнав о различных подходах, которые могут повлиять как на то, как мы формируем наши тесты, так и на то, как мы формируем само приложение.

Прежде чем начать, важно иметь в виду, что это статья-мнение, основанная на опыте. Некоторые из этих вещей могут вас устраивать, некоторые - нет.

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

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

Integration tests are a fundamental part of automated test suites. Хотя их не должно быть так много, как модульных тестов, если мы будем следоватьhealthy test pyramid. Опираясь на такие среды, как Spring, мы подвергаем себя значительному тестированию интеграции, чтобы снизить риск поведения нашей системы.

The more we simplify our code by using Spring modules (data, security, social…), the bigger a need for integration tests. Это становится особенно актуальным, когда мы перемещаем части нашей инфраструктуры в классы@Configuration.

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

Интеграционные тесты помогают нам укрепить доверие, но они имеют цену:

  • Это медленная скорость выполнения, что означает медленную сборку

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

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

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

Spring предлагает несколько вариантов тестирования веб-приложений, и большинство разработчиков Spring знакомы с ними, а именно:

  • MockMvc: имитирует API сервлета, полезно для нереактивных веб-приложений.

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

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

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

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

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

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

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

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

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

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

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

  • Мудрое использование профилей - как профили влияют на производительность

  • Пересмотр@MockBean – показа производительности имитационных обращений

  • Рефакторинг@MockBean – альтернативы для повышения производительности

  • Тщательно подумайте о полезной, но опасной аннотации @DirtiesContext – и о том, как ее не использовать

  • Использование тестовых слайсов - классный инструмент, который может помочь или встать на наш путь

  • Использование наследования классов - способ безопасной организации тестов

  • Государственное управление - передовой опыт, чтобы избежать ложных испытаний

  • Рефакторинг в юнит-тесты - лучший способ получить надежную и быструю сборку

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

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

Profiles - отличный инструмент. А именно, простые теги, которые могут включать или отключать определенные области нашего приложения. Мы могли бы с ними дажеimplement feature flags!

По мере того, как наши профили становятся богаче, возникает соблазн время от времени менять местами в наших интеграционных тестах. Для этого есть удобные инструменты, например@ActiveProfiles. Однакоevery time we pull a test with a new profile, a new ApplicationContext gets created.

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

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

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

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

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

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

  • Разместите наш тестовый профиль в централизованном месте - мы поговорим об этом позже

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

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

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

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

Every time @MockBean appears in a class, the ApplicationContext cache gets marked as dirty, hence the runner will clean the cache after the test-class is done. Что снова добавляет дополнительные секунды к нашей сборке.

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

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

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

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

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

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

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

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)
}

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

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

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

@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, можем ли мы утверждать результат и через 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, т.е. DBs

  • Наконец, наш тест ясно выражает цель системы: если вы проведете POST, вы сможете ПОЛУЧИТЬ пользователей

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

  • У нас может не быть конечной точки с «побочным эффектом»: здесь можно рассмотреть возможность создания «конечных точек тестирования»

  • Сложность слишком велика, чтобы охватить все приложение: можно рассмотреть срезы (мы поговорим о них позже)

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

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

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

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. Есть более эффективные способы обработки этих сценариев в интеграционных тестах, и мы рассмотрим некоторые из них в следующих разделах.

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

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

Также фреймворк позаботится о настройке самого минимума.

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

  • @JsonTest:  Регистрирует соответствующие компоненты JSON

  • @DataJpaTest: регистрирует компоненты JPA, включая доступную ORM

  • @JdbcTest: полезно для необработанных тестов JDBC, заботится об источнике данных и в БД памяти без излишеств ORM

  • @DataMongoTest: пытается предоставить настройку тестирования mongo в памяти

  • @WebMvcTest: фиктивный тестовый фрагмент MVC без остальной части приложения

  • … (Мы можем проверитьthe source, чтобы найти их все)

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

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

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

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

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works'. Таким образом, мы можем меньше беспокоиться об управлении состоянием или настройке фреймворка и сосредоточиться на текущей проблеме.

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

  • Spring Runner - или, предпочтительно, правила, на случай, если позже понадобятся другие бегуны.

  • профили - в идеале наш совокупныйtest profile

  • исходный конфиг - настройка состояния нашего приложения

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

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

    @Rule
    @JvmField
    val springMethodRule = SpringMethodRule()

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

4.7. Государственное управление

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

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

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

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

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

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

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

@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>

    @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
    }
}

4.8. Рефакторинг в юнит-тесты

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

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

Возможная схема для успешного достижения этой цели:

  • Определите интеграционные тесты, которые тестируют несколько сценариев основной бизнес-логики

  • Дублируйте комплект и выполните рефакторинг копии в модульные тесты - на этом этапе нам, возможно, потребуется разбить производственный код, чтобы сделать его тестируемым

  • Получить все тесты зеленый

  • Оставьте удачный пример пути, который достаточно примечателен в наборе интеграции - нам может понадобиться реорганизовать или объединить и изменить несколько

  • Удалить оставшиеся интеграционные тесты

Майкл Фезерс (Michael Feathers) рассказывает о многих методах достижения этой цели, а также о том, как эффективно работать с устаревшим кодом.

5. Резюме

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

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

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

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