Перейти к основному содержимому

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

· 12 мин. чтения

Задача: Наибольшая подстрока палиндром

Для заданной строки s, верните наибольшую подстроку палиндром входящую в s. Подстрока — это непрерывная непустая последовательность символов внутри строки. Стока является палиндромом, если она читается одинаково в обоих направлениях...

ANDROMEDA 42

1. Введение

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Мы можем подумать: зачем нам упорствовать, если все, что мы хотим протестировать, — это наш уровень 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 . Таким образом, мы закончим сохранение объекта (при условии, что это то, что делает служба).

Самым наивным подходом здесь было бы проверить побочный эффект: после отправки сообщения мой пользователь находится в моей БД, в нашем примере это будет использовать 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, т. Е. БД.
  • Наконец, наш тест ясно выражает намерение системы: если вы отправите сообщение, вы сможете получить пользователей.

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

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

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 : пытается предоставить установку для тестирования mongo в памяти.
  • @WebMvcTest : фиктивный фрагмент тестирования MVC без остальной части приложения.
  • … (мы можем проверить источник , чтобы найти их все)

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

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

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

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

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

Там мы могли бы задать все требования к тесту:

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

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

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

@Rule
@JvmField
val springMethodRule = SpringMethodRule()

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

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

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

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

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

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

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

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

В нашем примере мы предположим, что есть несколько репозиториев (из разных источников данных) и сервер 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<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
}
}

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

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

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

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

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

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

5. Резюме

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

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

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

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