Тестирование микросервисов: типы, моки, цирк и прочая чепуха

от автора

Большая часть кода в современном мире пишется в виде микросервисов. И хотя мне не сильно нравится сложившийся уклад, и я даже временами пытаюсь бороться с этой напастью, но жить приходится исходя из окружающей действительности, а потому многие вопросы приходится валидировать именно об этот факт. Спор о новом языке? А давайте посмотрим, как он подходит к микросервисам, какие плюсы даёт! Рассуждаем о тестировании? Отлично, но давайте делать это не в применении к тёплыму-ламповому периоду начала века, а к современности! И в этом месте внезапно может оказаться, что те аргументы, что мы пытаемся отстаивать или выслушиваем в тех или иных постах, давно устарели, неактуальны, слабы.

В этой статье я хотел бы поговорить о тестировании в современном мире. О лютом энтерпрайзе и о предложениях перенести его (энтерпрайза) опыт в мир опенсорс.

Итак, начнём.

Если вы хоть раз пытались тестировать микросервисы по «каноническим» методикам, то наверняка слышали всякие мантры: «мокай всё!», «не трогай настоящие базы!», «изолируй тесты!» и тому подобное. Чаще всего рекомендации пропагандируют юнит-тесты, ведь большинство языков встроенно поддерживают их написание, а инструменты и фреймворки созданы именно для этого. Однако в реальном мире, где взаимосвязи между сервисами играют первостепенную роль, юнит-тесты теряют свою эффективность, а их навязчивая бюрократия лишь усложняет жизнь разработчика.

Юнит-тесты, моки и фикстуры: бюрократия в квадрате

Юнит-тесты — это красивая идеология, которую преподносят на конференциях и в статьях: «Пиши юнит-тесты, они спасут мир!» Но когда речь заходит о микросервисной архитектуре, становится очевидно, что тестирование отдельных фрагментов кода не отражает реальное поведение системы.

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

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

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

Замечание: Как и любой другой инструмент, моки вполне могут быть полезны. Например, как имитаторы чужих сервисов — для моделирования внешних связей. Но там где вы управляете всем стеком, зачем создавать фальшивый оторванный от реальности мир?

Однако вернёмся к юнит тестам. Сформулирую тезис, который пусть и не популярен, но, по моему мнению близок к истине:

Избыточное количество юнит-тестов может даже вредить системе! Почему? Потому что фиксирует внутреннюю реализацию алгоритма, а не внешнее поведение, которое видит пользователь.

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

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

— Зачем ты удалил эти тесты?
— Новая библиотека использует иной алгоритм, а потому эти тесты больше не нужны.
— Ну, я не знаю, а мне кажется, что нужны. Пожалуйста, верни их или напиши столько же новых тестов.
— Но ни не нужны! Вот смотри…
— Если не нужен апрув на PR, так и скажи!

Интеграционные тесты: проверка живого мира

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

Такой подход дает максимальный выхлоп на вложенные усилия.

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

Декларативно-императивное тестирование

Критики могут возразить: «Автор всё критикует, но ничего не предлагает!» — «Критикуешь, так предлагай!»
Итак, давайте попробуем сформулировать, каким должно быть тестирование в современном микросервисном мире?

Основные требования:

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

  2. Ещё было бы неплохо уметь их генерировать (если возникнет такая необходимость, например, записывая нажатия кнопок в интерфейсе).

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

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

  5. Гибкость инфраструктуры. Тестовая система должна легко адаптироваться к меняющимся требованиям и технологическим вызовам.

Очевидно, что чистой декларативности тут недостаточно, поэтому придётся разбавить её элементами императивного стиля — для обработки сложных случаев. Чтобы разработчикам не приходилось изучать новый язык исключительно для тестирования, предлагается использовать… программирование на YAML!

Декларативно-императивное тестирование: YAML против традиционного кодинга

Ну вот, посмеялись и поехали дальше. А дальше у нас что? А дальше у нас управление командой и всякие там процессы. Процессы. Мотивация. М-да.

Вот вы пробовали заставить Go-программиста изучить, например, Lua с целью написания тестов к его трудам? Типа вот был у нас только Go, а теперь будет ещё и простенький Lua (или Python). А Вы попробуйте! И тут же столкнетесь с жестким сопротивлением: «Да на самой Go’шке всё проще!», «Фу!», «Зачем нужна эта пакость!».

А YAML в этом месте зайдёт. Почему? А потому что он воспринимается не как язык, а этакий стандарт конфигов. А для тестирования мы будем писать не программу, а конфиг для утилиты — интеграционного тестировщика!

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

Как бы мог выглядеть декларативно-императивный тест на YAML? А вот как-то вот так:

tests: - description: Создание пользователя   request:     type: post     address: /test/api/create-user     body:       username: "admin"       password: "securepass"     # ИМПЕРАТИВНЫЙ КУСОЧЕК! сохраняем ответ запроса в storage.admin     store: storage.admin   checks:   - type: status_code     expected: 201     description: Пользователь создан успешно   - type: content_type     expected: application/json     description: Ответ в формате JSON   - type: json_is     path: status     expected: ok     description: Статус операции — ok  - description: Создание продукта с использованием данных пользователя   request:     type: post     address: /test/api/create-product     body:       name: "Test Product"       # ИМПЕРАТИВНЫЙ КУСОЧЕК! используем id, сохранённый ранее в storage.admin       owner: "{{ storage.admin.id }}"   store: storage.product   # здесь тоже свой набор проверок

А так бы мог выглядеть лог запуска этого теста (TAP стандарт):

1..2 ok 1 - Создание пользователя   1..3   ok 1.1 - Проверка статус-кода (ожидался 201)   ok 1.2 - Проверка content_type (ожидался application/json)   ok 1.3 - Проверка json_is (status: ok) ok 2 - Создание продукта   1..1   ok 2.1 - Проверка наличия owner в ответе

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

  • Виды запросов: POST, GET и тому подобное.

  • Виды входных параметров: данные могут передаваться через формы, query-параметры, JSON, protobuf и в общем-то почти всё.

  • Для UI — это нажатия на кнопки и заполнения контролов (адресная строка — один из таковых).

  • Виды проверок: верификация по swagger-схеме или проверка отдельных параметров ответа (например, конкретных полей в JSON/html, заголовков и т.д.).

Добавим сюда какие-нибудь сложные случаи, вроде «заглянуть напрямую в БД» и всё равно не получим, что проектируемый нами язык не перегружен разнообразием деклараций: добавленная императивность (работа со storage) избавляет от необходимости иметь полноценный язык программирования для описания тестов. По моим подсчётам выходит менее двух десятков деклараций (вход: request, headers, query, form, multipart-form, json, xml, protobuf, выход: headers, status, json, xml, protobuf, html чеки вида (path — expectedValue)).

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

  • определяет требуемую аутентификацию/авторизацию (при необходимости выполняя запросы к каким-то микросервисам)

  • запрашивает у микросервисов создание нужных ресурсов (например цепочки «склад» -> «полка» -> «товар на полке»)

  • выполняет собственно тестовый запрос

  • проверяет результаты

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

Подъём тестовой инфраструктуры: CI и локальный запуск

Один из важнейших аспектов интеграционного тестирования — подъем тестовой инфраструктуры. Однако в современное время это не выглядит сложным: у нас есть такие прекрасные инструменты, как docker-compose, kuber и тому подобное. Конфиг для этих систем может быть составлен вручную (топология кластера меняется нечасто, поэтому «вручную» — нормальный подход) или автоматически сгенерирован утилитой на основе конфигов каждого микросервиса.

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

  1. Полный запуск всего окружения и его тестов из корневого каталога кластера.
    Подходит для CI и полного ручного прогона всех тестов.

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

  2. Локальный запуск отдельного теста.
    Если для отладки требуется запустить тест-одиночку, достаточно перейти в подкаталог конкретного сервиса и выполнить команду, например, us test path/to/test.yaml. В этом случае тестовая система должна «погасить» соответствующий контейнер для данного сервиса и запустить его копию локально (возможно, на тех же портах), позволяя быстро повторять цикл «исправил — протестировал — ещё раз исправил».

Для поддержки такого сценария каждый сервис должен иметь свой настроечный конфиг (например, service-config.yaml), где описаны его зависимости, а также должен существовать общекластерный конфиг (cluster-config.yaml), содержащий ссылки на docker-compose, Kubernetes-конфиги и прочее. Пример структуры может выглядеть так:

cluster/ ├── cluster-config.yaml   # Общекластерный конфиг (ссылки на docker-compose/Kuber и т.д.) ├── serviceA/ │   ├── service-config.yaml   # Конфиг сервиса A (зависимости и настройки) │   └── tests/                # Тесты для сервиса A │       └── test.yaml ├── serviceB/ │   ├── service-config.yaml │   └── tests/ │       └── test.yaml └── ... 

Такой подход обеспечивает гибкость: можно запускать тесты в полном окружении или по отдельности, что идеально поддерживает паттерн «исправил — запустил — ещё раз исправил». А попутно все эти конфиги можно ещё использовать, например, для автогенерации кода сервисов.

Функциональное программирование и типы: лекарство от всех бед?

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

Но задумайтесь: что из описанного выше они действительно могут решить? Да функциональный язык мог бы быть хорош для описания самих тестов, однако разработчики будут сопротивляться изучению нового языка только ради тестирования — а YAML вполне этот психологический барьер преодолевает.

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

Вывод

Распространённые рекомендации по тестированию воспринимаются как догмы, но с 2010 года реальность сильно поменялась и теперь показывает несколько иное:

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

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

  • Моки и фикстуры: если вы управляете всем стеком, зачем создавать фальшивый мир, оторванный от реальности? (Да, имитаторы внешних сервисов могут быть полезны для моделирования внешних связей, но и только!)

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

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

  • UI-тестирование: может быть описано в той же парадигме, где вместо HTTP-операций используются действия пользователя в приложении или браузере — нажатия кнопок, заполнение форм, выбор элементов.

  • Подъём тестовой инфраструктуры: наличие индивидуальных конфигов для каждого сервиса и общекластерного конфига позволяет запускать тесты как целиком, так и по отдельности. При полном запуске создается единое окружение, максимально приближенное к продакшену, а при локальном запуске теста то же самое, но система «гасит» один из контейнеров и поднимает сервис локально, что поддерживает цикл «исправил — запустил — ещё раз исправил».

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

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

Лечите причину проблемы, а не боритесь с её симптомами — и ваш продукт будет работать, как положено, не только на бумаге.

P.S. Программируя в подобной парадигме в лютом энтерпрайзе, я наблюдал следующие соотношения строк кода к строкам тестов: 60% кода к 40% тестов. При этом качество получалось на уровне «31 декабря мы не боимся разложить новый релиз в прод!».

P.P.S. Вот бы кто такое для OpenSource разработал, а? Увы, вижу только зачатки подобных систем — лишь отдельные кусочки. Может, кто-то засядет и соберёт воедино?


ссылка на оригинал статьи https://habr.com/ru/articles/882902/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *