Большая часть кода в современном мире пишется в виде микросервисов. И хотя мне не сильно нравится сложившийся уклад, и я даже временами пытаюсь бороться с этой напастью, но жить приходится исходя из окружающей действительности, а потому многие вопросы приходится валидировать именно об этот факт. Спор о новом языке? А давайте посмотрим, как он подходит к микросервисам, какие плюсы даёт! Рассуждаем о тестировании? Отлично, но давайте делать это не в применении к тёплыму-ламповому периоду начала века, а к современности! И в этом месте внезапно может оказаться, что те аргументы, что мы пытаемся отстаивать или выслушиваем в тех или иных постах, давно устарели, неактуальны, слабы.
В этой статье я хотел бы поговорить о тестировании в современном мире. О лютом энтерпрайзе и о предложениях перенести его (энтерпрайза) опыт в мир опенсорс.
Итак, начнём.
Если вы хоть раз пытались тестировать микросервисы по «каноническим» методикам, то наверняка слышали всякие мантры: «мокай всё!», «не трогай настоящие базы!», «изолируй тесты!» и тому подобное. Чаще всего рекомендации пропагандируют юнит-тесты, ведь большинство языков встроенно поддерживают их написание, а инструменты и фреймворки созданы именно для этого. Однако в реальном мире, где взаимосвязи между сервисами играют первостепенную роль, юнит-тесты теряют свою эффективность, а их навязчивая бюрократия лишь усложняет жизнь разработчика.
Юнит-тесты, моки и фикстуры: бюрократия в квадрате
Юнит-тесты — это красивая идеология, которую преподносят на конференциях и в статьях: «Пиши юнит-тесты, они спасут мир!» Но когда речь заходит о микросервисной архитектуре, становится очевидно, что тестирование отдельных фрагментов кода не отражает реальное поведение системы.
Иногда в этом месте различные талмуды рекомендаций немного смягчаются и разрешают шагнуть от юнит-тестирования к интеграционному, но настаивая на моках, фикстурах и прочей мишуре, себя же и отменяют
Идя на поводу методичек, разработчики вынуждены тратить часы на создание искусственных копий реального мира, которые не просто мешают, но и требуют постоянного обслуживания. Следуя рекомендациям «мокайте весь мир, читай: сводите всё к чистым функциям», мы отдаляемся от максимальной эффективности и попадаем в ловушку избыточной сложности.
Вместо проверки настоящего кода тщательнейшим образом верифицируется заранее подготовленная фиктивная модель, и при малейшем несовпадении между моком и реальностью реальные баги остаются незамеченными.
Замечание: Как и любой другой инструмент, моки вполне могут быть полезны. Например, как имитаторы чужих сервисов — для моделирования внешних связей. Но там где вы управляете всем стеком, зачем создавать фальшивый оторванный от реальности мир?
Однако вернёмся к юнит тестам. Сформулирую тезис, который пусть и не популярен, но, по моему мнению близок к истине:
Избыточное количество юнит-тестов может даже вредить системе! Почему? Потому что фиксирует внутреннюю реализацию алгоритма, а не внешнее поведение, которое видит пользователь.
Это затрудняет рефакторинг, так как любое изменение внутренней логики приводит к необходимости переписывать множество тестов, даже если интерфейс остается неизменным.
Поднимите руки, кто НЕ сталкивался с подобным диалогом на ревью:
— Зачем ты удалил эти тесты?
— Новая библиотека использует иной алгоритм, а потому эти тесты больше не нужны.
— Ну, я не знаю, а мне кажется, что нужны. Пожалуйста, верни их или напиши столько же новых тестов.
— Но ни не нужны! Вот смотри…
— Если не нужен апрув на PR, так и скажи!
Интеграционные тесты: проверка живого мира
Интеграционные тесты наиболее эффективны, поскольку проверяют систему в «живом», полностью собранном окружении, моделируя поведение реальных, а не искусственных пользователей в вакууме. Они демонстрируют, как работает ваш код с реальными зависимостями: базами данных, очередями, сторонними сервисами и микросервисами, разработанными вашими коллегами.
Такой подход дает максимальный выхлоп на вложенные усилия.
Когда вместо реальной интеграции вы создаете искусственную изоляцию («мокайте весь мир!»), тестовая модель отдаляется от условий эксплуатации. Это приводит к лишней работе и зачастую бесполезным проверкам.
Декларативно-императивное тестирование
Критики могут возразить: «Автор всё критикует, но ничего не предлагает!» — «Критикуешь, так предлагай!»
Итак, давайте попробуем сформулировать, каким должно быть тестирование в современном микросервисном мире?
Основные требования:
-
Простота написания тестов. Тесты должны быть настолько понятными, чтобы их можно было писать быстро и без излишней громоздкости (и при этом хотелось бы не думать о вопросах поднятия окружения).
-
Ещё было бы неплохо уметь их генерировать (если возникнет такая необходимость, например, записывая нажатия кнопок в интерфейсе).
-
Доступность для описания сложных взаимосвязей. Даже самые запутанные зависимости между сервисами должны быть изложены так, чтобы их было легко понять и поддерживать.
-
Поддержка многоязычности. В условиях, когда микросервисы пишутся на разных языках, язык, на котором формулируются тесты должен быть максимально универсальным.
-
Гибкость инфраструктуры. Тестовая система должна легко адаптироваться к меняющимся требованиям и технологическим вызовам.
Очевидно, что чистой декларативности тут недостаточно, поэтому придётся разбавить её элементами императивного стиля — для обработки сложных случаев. Чтобы разработчикам не приходилось изучать новый язык исключительно для тестирования, предлагается использовать… программирование на 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, описывающем весь кластер, можно указать ссылку на такой конфиг, и тестовая система сможет действовать сразу в двух режимах:
-
Полный запуск всего окружения и его тестов из корневого каталога кластера.
Подходит для CI и полного ручного прогона всех тестов.Будучи запущена в каталоге с кластером, тестовая утилита проходит по всем подкаталогам, создает необходимые контейнеры, запускает кластер через docker-compose или Kubernetes и выполняет все тесты параллельно, максимально имитируя реальные условия работы системы и минимизируя время выполнения тестов.
-
Локальный запуск отдельного теста.
Если для отладки требуется запустить тест-одиночку, достаточно перейти в подкаталог конкретного сервиса и выполнить команду, например,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/
Добавить комментарий