Серьезный гайд по интеграционному тестированию

Тестирование сейчас — не просто зеленые и красные иконки в IDE, как когда-то. У современной QA-команды есть все: графики, логи, целевые метрики и подробные репорты. Так что тестирование — уже не дополняющая, а неотъемлемая часть цикла разработки.

Но все еще, когда разработчики говорят о тестировании, они по привычке имеют в виду не QA в целом, а юнит-тестирование, то есть лишь то, что относится к разработчикам непосредственно. А как насчет интеграционного, да и других видов Quality Assurance? Здесь не все идеально: многие проджект-менеджеры считают, что средняя часть QA-пирамиды слишком уж трудоемкая, «медленная», и стараются ее избегать или по возможности сократить длительность, переходя сразу к e2e-этапу, что иногда оправдано, но безусловно является плохой практикой

Надежные инструменты, библиотеки и хорошо отлаженные CI/CD-пайплайны могут сделать интеграционное тестирование таким же быстрым и простым, как модульное.

Модульные vs интеграционные тесты

Юнит-тесты

Целью модульного тестирования является проверка поведения отдельно рассматриваемых компонентов, то есть модулей, для простоты называемых юнитами. 

Кстати, всегда есть двусмысленность в определении слова юнит. Это класс? Функция? Или, может, целый пакет? Зависит от проекта. 

Так как гайд у нас с претензией на серьезность, то для начала уточним понятие Test Driven Development (вкратце — здесь). TDD — это нестандартный подход к разработке, когда тесты пишутся до того, как будет написан код

Существует две «школы» TDD: так называемая Детройтская (или классическая) и так называемая Лондонская (или мокистская, то есть построенная на моках).

  • Детройтская школа пропагандирует дизайн по принципу Inside-Out. Поэтому, когда команда начинает работать над проектом, то сначала создается модель домена; а уровень API разрабатывается в последнюю очередь. Юнит-тесты здесь как бы управляют потоком архитектуры. Это можно сравнить с многослойной «луковичной» структурой, когда каждый юнит-тест «покрывает» приложение новым слоем. А приемочное тестирование — последний этап, на котором окончательно проверяется, что продукт работает правильно.

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

  • Лондонская школа TDD подходит к задаче с другой стороны (то есть  Outside-In-принцип). Она декларирует, что разработка приложения должна начинаться с разработки API. А модель домена разрабатывается потом.

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

Итак. Существует два противоположных подхода к юнит-тестированию. Как тогда определить его? Я бы сформулировал его суть следующим образом.

Юнит-тест — это вид автоматизированного тестирования, обладающий следующими свойствами:

  1. Поведение юнит-теста не зависит от любого состояния вне запущенного приложения.
  2. Юнит-тесты могут выполняться параллельно, не влияя друг на друга.

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

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

Это вид автоматизированного тестирования, обладающий следующими характеристиками:

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

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

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

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

Почему интеграционные тесты будут нужны всегда

Уставшие от юнит-тестов разработчики, которые рассматривают интеграционные тесты как чрезмерно сложные, ненужные, могут задаться вопросом: зачем вообще тесты интеграции? Мы что, не можем просто пропустить этот средний этап, максимально быстро пройти нижний, то есть «модульный» этап, тем более что инструментов для этого предостаточно, и завершить SDLC-цикл хорошо отработанными сквозными и/или приемочными?

Да, действительно, интеграционное тестирование сопряжено с некоторыми, достаточно сложными проблемами (обсудим их далее). Но если кратко: юнит-тестов для любого современного приложения — все еще недостаточно.

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

Вот, к примеру, реализация на Java с использованием Spring Boot:

public interface BookRepository extends JpaRepository<Book, Long> {

  @Query("""
      SELECT b.id as id, b.name as name, AVG(r.value) as avgRating
          FROM Book b
      LEFT JOIN b.reviews r
      WHERE b.id = :id;""")
  Optional<BookCard> findBookWithAverageRating(@Param("id") long bookId);

}

@Service
public class BookService {

  private final BookRepository bookRepository;
  private final AuditService auditService;

  public BookCard getBookById(long bookId) {
    final var book =
        bookRepository.findBookWithAverageRating(bookId)
            .orElseThrow();
    auditService.bookRequested(book);
    return book;
  }

}

Мы могли бы просто написать простой юнит-тест для класса BookService, но, подумаем, достаточно ли этого, чтобы быть уверенным в коде? Ответ — нет же. Потому что этот код неправильный. Там есть одна маленькая деталь, которую легко упустить. Посмотрите на эту строку в запросе:

WHERE b.id = :id;

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

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

Предположим, что нам нужно получить пользователя по ID, с ролями, напишем такой запрос:

@Transactional(readOnly = true)
public class UserRepository {

  @PersistenceContext
  private EntityManager em;

  public Optional<UserView> findByIdWithRoles(Long userId) {
    List<Tuple> tuples = em.createQuery("""
            SELECT u.id, u.name, r.name FROM User u
            JOIN u.userRoles ur
            JOIN ur.role r
            WHERE u.id = :id""", Tuple.class)
        .setParameter("id", userId)
        .getResultList();
    // transform to dto
    return userView;
  }

}

Запрос не приводит к рантайм-исключениям, но его поведение не будет всегда корректным. Видите, мы поставили JOIN (алиас для INNER JOIN) вместо LEFT JOIN. И это значит, что мы не найдем там пользователей, у которых нет ролей.

Можете проверить вышеупомянутые проблемы вручную. Просто запустите приложение локально и отправьте HTTP-запросы в Postman.

Цель тестирования — автоматизировать валидацию бизнес-функций, чтобы повысить эффективность пайплайна доставки. Если вы знаете, что ваш код был полностью протестирован перед тем, как слить его в main-ветку, деплоить новые версии гораздо проще. Но, если продукт верифицирован лишь частично, то есть большой шанс, что такие полумеры могут полностью положить прод.

Придуман даже специальный термин для обозначения этой проблемы: Fear-Driven Development

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

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

Сложности интеграции

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

С чего начать? Первый шаг — установка окружения; и она сложнее, чем кажется.

  1. Приложение может взаимодействовать с внешними зависимостями (сейчас чаще всего это PostgreSQL, Kafka, MongoDB и так далее).
  2. Также нужно обеспечить, чтобы интеграционный сетап воспроизводился на любой машине, ведь большинство проектов создается разными командами разработчиков.
  3. Вопросы обслуживания. Например, если в один прекрасный день продукт начнет зависеть от внешнего сервиса, придется обновлять окружение.
  4. Наконец, весь домен должен беспроблемно работать в непрерывной интеграции.

Шаблоны интеграции

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

Ручная конфигурация окружения

Идея простая: если приложение зависит от X, установите X на свой компьютер. Каждый раз, когда запускаете интеграционные тесты, вводите свойства в соответствии с конфигурационным файлом.

Кажется, что это естественный подход, не так ли? В конце концов, именно так мы и запускаем приложение. Значит, не должно быть проблем? Вроде бы не должно. Но:

  1. Разработчики несут полную ответственность за поддержку окружения. Если кто-то обновил версию базы данных, команда должна это учесть. Иначе тестирование не будет вполне надежным.
  2. Это создает дополнительные трудности при настройке. Управление открытыми портами — наименьшая из проблем.
  3. Эта техника неприменима к CI-процессам.

Можете не соглашаться с последним пунктом. Например, если приложению нужен PostgreSQL, можно запустить экземпляр на удаленном сервере. Тогда любой желающий сможет подключиться к нему во время pull request билда.

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

Также можно попытаться создавать и удалять базы данных динамически при каждом CI-билде, но это будет довольно сложно. Существуют более эффективные подходы, которые рассмотрим ниже.

Vagrant

Vagrant автоматизирует ручной подход. Нужно объявить зависимости в Vagrantfile и выполнить команду vagrant up. После этого инструмент запускает пакет виртуальных машин. Каждая из них представляет отдельную внешнюю зависимость.

В чем преимущества: 

  1. Инструмент следует методологии Infrastructure as code (IaC). Конфигурация системы представляет собой простой текстовый файл. Разработчики могут изменять его с помощью pull request.
  2. Не нужно беспокоиться о версионировании или инфраструктуре. Vagrant подхватывает эти изменения.
  3. Ручное управление не требуется. И нет проблем с открытыми портами и конфигурациями.

Но к сожалению есть и проблемные моменты:

  1. Vagrant нужна довольно мощный компьютер, так как виртуальные машины требовательны к ресурсам.
  2. Инициализация не всегда быстрая.
  3. Интеграция CI сложна (а иногда и невозможна). Технически Vagrant не является инструментом тестирования, скорее это инструмент разработки. Его основная цель — подготовить окружение для локальной разработки. Хотя он вполне пригоден для интеграционного тестирования, он не решает всех проблем.

Docker-Compose

Docker — это революция в разработке, хотя, кажется, в самой идее не было ничего революционного. Он использует Linux namespaces и CGroups, которые существовали уже давно. Подобные решения уже существовали (например LXC Containers), но именно Docker сделал использование контейнеров прозрачным и удобным. В простейшем случае достаточно одной команды docker run.

Docker-Compose — это следующий эволюционный шаг. Он позволяет запускать несколько контейнеров on-demand. Нужно создать файл docker-compose.yml, чтобы декларативно определить все необходимые сервисы.

Звучит как отличная возможность решить проблемы интеграционного тестирования. Что предлагает Docker-Compose:

  1. Docker-Compose следует методологии IaC, как и Vagrant.
  2. Поскольку Docker является кроссплатформенным инструментом, окружение воспроизводится везде.
  3. Простая конфигурация. Нужно просто поставить Docker, а затем выполнить команду docker-compose up.

Является ли Docker-Compose идеальным решением? Ну, почти. Интеграция с CI возможна, но бывает сложна.

Проблема кроется в природе Docker-Compose. По умолчанию Docker-контейнеры недоступны из операционной системы хоста. Нужно прописать порты, которые должны принимать пакеты, и передавать их контейнеру. Эти порты не должны использоваться никакими другими программами. Поскольку docker-compose.yml — обычный текстовый файл, необходимо задавать порты статически. Например, возможный способ запуска БД MySQL:

version: '3.3'
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_DATABASE: 'db'
      MYSQL_USER: 'user'
      MYSQL_PASSWORD: 'password'
      MYSQL_ROOT_PASSWORD: 'password'
    ports:
      - '5555:3306'
    expose:
      - '3306'

Порт 5555 принимает соединение. ОК, пока все нормально. А как мы узнаем, что порт открыт на CI-ноде? Существует способ, позволяющий обойти это ограничение.

  1. Вставляем вместо фактического номера порта плейсхолдер (например, $MYSQL_PORT).
  2. Запускаем специальный скрипт, который проверит все порты и выберет первый доступный. Затем процесс заменит плейсхолдер на доступный порт.
  3. Запускаем контейнеры.
  4. Запускаем тесты.
  5. Останавливаем контейнеры.

Это все? Проблем больше нет? Есть.

1. Контейнеры могут продолжать работать при сбоях сборки.

Предположим, что что-то пошло не так и ОС закрыла процесс, выполнявший сборку. Что произойдет с контейнерами? Ничего. Они продолжат работать как обычно. Если такой сценарий произойдет несколько раз, это приведет к излишнему расходу ресурсов.

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

2. Сама сборка может выполняться внутри Docker-контейнера.

Это обычный подход для многих CI-провайдеров. Он помогает запускать разные сборки изолированно. Но это означает, что теряется возможность запускать Docker-контейнеры.

Есть возможность запускать Docker-контейнеры внутри другого Docker-контейнера. Условия таковы:

  • Внешний контейнер должен запускаться в привилегированном режиме (-privileged=true).
  • Нужно установить docker внутри запущенного контейнера.

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

Testcontainers

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

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

Вот простой Java-тест с JUnit5 для интеграции Testcontainers. Пример кода я взял из документации к библиотеке.

@Testcontainers
class MixedLifecycleTests {

  // will be shared between test methods
  @Container
  private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer();

  // will be started before and stopped after each test method
  @Container
  private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
      .withDatabaseName("foo")
      .withUsername("foo")
      .withPassword("secret");

  @Test
  void test() {
    assertTrue(MY_SQL_CONTAINER.isRunning());
    assertTrue(postgresqlContainer.isRunning());
  }

}

Ключевое отличие подхода Docker-Compose от подхода с Testcontainers заключается в динамической конфигурации. Контейнеры описываются в виде обычного Java-кода. Это дает больше гибкости в настройке окружения.

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

В чем преимущества Testcontainers

  1. Проще настройка. Можно настроить контейнеры нужным образом, используя тот же язык, что и код приложения.
  2. Окружение легко воспроизвести. Даже если нет возможности установить Docker на машину, это не страшно, ведь можно настроить библиотеку на подключение к Docker-сервису на удаленном хосте.
  3. Существуют десятки готовых решений упакованы в контейнеры Docker и готовы к использованию. Если не найдете нужного, всегда можно воспользоваться Generic Container.
  4. Хотя Java является основным языком для Testcontainers, существует множество других опций: Rust, Python, Go, Scala или NodeJS.

Звучит заманчиво, но как насчет потенциальных проблем? Как сказано выше, Docker-Compose может быть сложно интегрировать в CI-пайплайн. У Testcontainers те же проблемы?

1. Контейнеры могут продолжать работать при сбоях сборки.

Да, у Testcontainers была такая проблема. Но с появлением Ryuk имплементация контейнеров перестала быть актуальной. Идея простая: помимо обязательных зависимостей проекта, Testcontainers запускает Ryuk. Его задача — отслеживать статус других контейнеров, отправляя heartbeat-опросы. Когда контейнер перестает отвечать, Ryuk удаляет его вместе с соответствующим образом, сетью и томами.

2. Сама сборка может выполняться внутри Docker-контейнера.

Библиотека может обнаружить, что само приложение находится внутри Docker-контейнера. Чтобы преодолеть это препятствие, необходим wormhole-паттерн.

Пример кода:

docker run -it --rm \
       -v $PWD:$PWD \
       -w $PWD \
       -v /var/run/docker.sock:/var/run/docker.sock \
       maven:3 \
       mvn test

Когда запускаете сборку, вам следует назначить том и рабочий каталог, а также смонтировать файл docker.sock. Testcontainers сделает все остальное.

Скорее всего, не придется самостоятельно выполнять все эти настройки. Большинство CI/CD-инструментов по умолчанию поддерживают этот паттерн.

Итак

Интеграционное тестирование — это действительно сложно. А с другой стороны, сейчас оно проще, чем когда-либо. Docker, Testcontainers и CI/CD сделали интеграцию проще и прозрачнее.

Иногда говорят, что даже 100% покрытие кода не доказывает его 100% качество. А я могу уверенно сказать, что отсутствие интеграционных тестов — это точный признак некачественного продукта.»

Источник

Линкедин автора

Видео

Лекция ВШЭ по интеграционному тестированию (около десятка примеров):


Что такое интеграционное тестирование — ознакомительный гайд для джунов

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас