Ранние решения, чтобы свести к минимуму ваши будущие сожаления

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

  1. У всех разные определения «интеграционного тестирования».
  2. Другие проблемы по сравнению с модульным тестированием
  3. Принятие компромиссных решений относительно зависимостей
  4. Что обычно ломается в бессерверных приложениях
  5. Стоит ли имитировать сервисы AWS локально или нет

Это третья часть серии статей о тестировании бессерверных приложений. Часть 1 и часть 2 охватывали модульные тесты и общий дизайн тестирования. Вам не нужно читать первые две части, чтобы понять этот пост.

1. У всех разные определения «интеграционного тестирования».

Давайте посмотрим на простую трехуровневую архитектуру ниже. Для сквозного тестирования тесты будут проверять фактические компоненты со всех трех уровней (от пользовательского интерфейса до базы данных). Это просто. А как насчет интеграционного тестирования?

Каждая команда, с которой я работал, всегда имела разные представления о том, что составляет объем интеграционного тестирования. На картинке выше интеграционное тестирование - номер 1, 2 или 3? Заглянем в Википедию.

Интеграционное тестирование - это этап тестирования программного обеспечения, на котором отдельные программные модули объединяются и тестируются как группа… - Википедия

Исходя из определения «модулей», все три варианта можно рассматривать как интеграционное тестирование. На первый взгляд, тестирование только UI или API-интерфейса не кажется интеграционным тестом. Однако наши интерфейсные инженеры используют термин «интеграционный тест» для описания своих интерфейсных тестов, которые проверяют, что их модули работают вместе (например, компоненты React, хранилище Redux и редукторы и т. Д.). Люди, занимающиеся фронтендом, придерживаются разных взглядов на то, с чего им следует начинать издеваться над вещами. То же самое происходит, когда мы тестируем только серверную часть API под номером 2. Наш API состоит из нескольких классов, мы можем тестировать обработчики и службы вместе, но имитируем базу данных.

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

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

2. Различные проблемы по сравнению с модульным тестированием.

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

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

Эти проблемы означают, что интеграционное тестирование требует гораздо большего обслуживания. Также требуется больше времени, чтобы охватить все возможные случаи (по сравнению с модульными тестами). Запуск всего тестового костюма может занять несколько минут, что замедлит цикл разработки. Когда это занимает больше минуты, я замечаю, что наши CI / CD ломаются чаще, поскольку люди, как правило, забывают запускать интеграционные тесты локально, прежде чем нажимать. В результате мы попытались ускорить процесс, запустив тесты параллельно, и обнаружили зависимости между тестовыми примерами.

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

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

3. Принятие компромиссных решений относительно зависимостей.

Сервис-ориентированная (или микросервисная) архитектура усложняет интеграционное тестирование. Наш API-интерфейс (или даже пользовательский интерфейс) может вызывать несколько сервисов. И здесь возникает несколько вопросов:

  1. Должны ли мы имитировать эти службы (или использовать фиктивный сервер API) с некоторыми шаблонными ответами или вызывать фактические службы?
  2. Если мы называем фактические услуги, следует ли называть их средами разработки, принятия или производства.
  3. Как обеспечить стабильность этих сред при запуске тестов в конвейере CI / CD?
  4. Как мы очищаем постоянные данные, созданные нашими тестами на их сервисах? Если мы назовем их производственными, как мы можем избежать искажения производственных данных?
  5. Если мы имитируем любую из этих служб, как мы можем быть уверены, что в эмуляторе нет ошибок или различий, не проверив фактическую интеграцию?

Это очень быстро усложняется. Выбор одного варианта вместо другого - это обычно компромисс. Мы должны сбалансировать уверенность в том, что наша система в целом работает (проверка системы), со скоростью и надежностью наших тестов. У нас также есть разные решения, когда речь идет о внутренних сервисах (например, тех, которые принадлежат нашим соседним командам) и внешних сервисах (например, тех, которые принадлежат поставщикам SaaS).

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

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

  • Уверенность в том, что все наше приложение / служба будет работать (например, если мы обратимся к среде принятия, как мы можем быть уверены, что она также работает с производственной версией. Можем ли мы верить, что они будут обратно совместимы?)
  • Скорость нашего теста (например, вызов фактического сервиса по сравнению с имитацией)
  • Надежность нашего теста (например, насколько ненадежным он будет, если мы вызовем непроизводственные внутренние службы?)
  • Насколько хрупкими будут тесты (например, вызовут ли изменения в других сервисах массовое переписывание? Как часто они меняются?)
  • Насколько легко найти ошибки, если наш тест не прошел (например, если мы вызываем внешнюю службу, как мы узнаем, что это не проблема на нашей стороне, если тест не прошел? Должны ли мы также запускать наши тесты при их повторном развертывании, чтобы мы знаете кто его сломал?)

4. Что обычно ломается в бессерверных приложениях

Все, что описано выше, является общим соображением для интеграционных тестов в любой среде. Однако в бессерверном мире соединения, которые мы хотим обеспечить правильной работой, - это интеграция между API Gateway, Lambda и RDS. Если предположить, что наши модульные тесты имеют 100% покрытие и проходят успешно, что еще может пойти не так?

Подумайте, прежде чем прокручивать страницу вниз.

Ответ кроется в точках интеграции между сервисами. Они могут быть любыми из следующих:

  1. Конфигурация или сопоставление API шлюза API с Lambda неверны
  2. Параметры из API Gateway могут отсутствовать, отличаться от ожидаемых или иметь неправильные комбинации.
  3. Отсутствует разрешение в ролях IAM или политиках ресурсов
  4. Схема таблицы в RDS может не работать с SQL-запросом от Lambda (опечатка или обновление схемы во время развертывания)

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

В бессерверных архитектурах наши сервисы обычно охватывают множество различных внешних сервисов. Вышеупомянутая архитектура очень упрощена. В реальном производственном приложении обычно используется несколько шлюзов API, кеш, хранилище (например, S3), служба оркестровки (например, пошаговые функции), специализированные базы данных, такие как ElasticSearch, а также очереди сообщений и службы потоковой передачи событий. Так что ошибки в точках интеграции встречаются чаще.

В статье Модульное тестирование переоценено Голуб утверждает, что объем тестирования (unit → end-to-end) не имеет линейной корреляции со стоимостью и скоростью, как мы (подсознательно) предполагаем, глядя на пирамиду тестирования. Вот график из его статьи.

То же самое я скажу о графике масштабов и преимуществ для бессерверных приложений. Чем больше сервисов мы введем, тем больше вероятность того, что мы столкнемся с пограничным случаем в одной из точек интеграции. Ценность, которую мы получаем от увеличения объема (большей интеграции), не зависит от значения. Я считаю, что мы получаем гораздо больше, чем платим.

5. Следует ли имитировать сервисы AWS локально или нет.

В I Love My Local Farmer мы решили, что не хотим имитировать сервисы AWS в среде принятия.

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

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

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

5.1 Не использовать эмуляцию

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

Аргументом против этого метода является то, что развертывание и выполнение интеграционных тестов занимает много времени.

Но насколько они медленные? Это зависит от инструментов. Давайте углубимся в детали:

SAM и CDK развертывают изменения через CloudFormation. Развертывание займет минимум 30–60 секунд, даже если мы изменим одну строку кода. Этого количества времени более чем достаточно, чтобы заставить меня бродить по Reddit и забыть, что я делаю.

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

Для Serverless Framework он также проходит через CloudFormation (30–60 секунд). Но у него есть возможность выполнить развертывание с горячей заменой (--function или -f), поэтому изменение кода займет всего несколько секунд.

Бессерверный стек (SST) - это новый инструмент, который решает эту проблему другим способом. Они предоставляют функцию Live Lambda Development, которая развертывает оболочку Lambda-функции, которая подключается к нашей локальной машине. Функция оболочки просто направляет любые запросы к коду на нашей локальной машине. Машина выполнит код и вернет результат обратно в оболочку Lambda. Следовательно, нам не нужно повторно развертывать какие-либо изменения кода (поскольку функция оболочки всегда делает одно и то же, перенаправляя запросы на ваш локальный компьютер). Запрос может занять на несколько секунд больше из-за прыжков. Однако он не поддерживает Java, требующую компиляции (может быть отличным выбором для NodeJS), и мы не тестируем ту же среду, которая будет развернута в производственной среде, поэтому некоторые конфигурации могут не тестироваться.

Вот сводка сравнения:

Однако время выполнения теста для асинхронных приложений может быть очень большим. Представьте себе использование нескольких очередей SQS для асинхронной интеграции Lambda. Задержка от сети (50–250 мсек за передачу в оба конца, в зависимости от региона) и фактического обслуживания (для опроса сообщений) может быстро увеличиться. Если он также передает данные размером ›256 КБ, он должен пройти через S3, что добавляет больше циклов туда и обратно. В результате задержка может возрасти с нескольких секунд до более минуты при переключении с локальной эмуляции на фактические службы.

5.2 Использование (полной или частичной) эмуляции

Люди из другого лагеря предпочитают подражать сервисам на местном уровне. Они заменяют все (или некоторые) сервисы AWS следующими инструментами:

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

Однако есть две основные проблемы:

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

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

Вердикт об использовании эмуляции

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

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

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

Кроме того, возможно, что каждый разработчик в одной команде локально использует разные методы. Таким образом, в идеале интеграционное тестирование должно выполняться как в эмулируемой, так и в реальной среде. Для запуска должны потребоваться только конечные точки службы, и вы можете передавать их через переменные среды. Таким образом, вы можете повторно использовать один и тот же тестовый код как для конвейера CI / CD, так и для локальных машин.

Заключение

Это было давно! Мы затронули многие аспекты этой темы, поэтому позвольте мне резюмировать наши рекомендации, основанные на нашем собственном опыте:

  1. Четко договоритесь со всеми в вашей команде об объеме тестирования. У всех разные определения. Будьте предельно ясны, какие зависимости имитировать / эмулировать.
  2. Достигните общего соглашения между внутренними командами о том, как тесты интеграции сервисов будут вызывать друг друга.
  3. Написание интеграционных тестов становится все более важным для бессерверных приложений. При большем количестве движущихся частей весьма вероятно, что ошибка будет в точке интеграции, которую нельзя тестировать отдельно.
  4. Не эмулируйте сервисы в своем конвейере CI / CD. Используйте реальную среду AWS для тестирования интеграции.
  5. У использования эмуляции для локальной разработки есть свои плюсы и минусы. Опыт может сильно отличаться с разными инструментами. Попробуйте разные методы, чтобы найти лучший вариант для вашего приложения.

В следующем посте мы вернемся к кодированию и рассмотрим, как мы кодируем наше интеграционное тестирование для нашего бессерверного проекта Java.