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

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

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


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

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

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

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

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

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

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

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

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

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

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

— Зачем ты удалил эти тесты?
— Новая библиотека использует иной алгоритм, а потому эти тесты больше не нужны.
— Ну, я не знаю, а мне кажется, что нужны. Пожалуйста, верни их или напиши столько же новых тестов.
— Но ни не нужны! Вот смотри...
— Если не нужен апрув на 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 разработал, а? Увы, вижу только зачатки подобных систем — лишь отдельные кусочки. Может, кто-то засядет и соберёт воедино?

Комментарии (11)


  1. Dgolubetd
    15.02.2025 22:43

    Как вы интеграционными тестами тестируете поведение приложения в нештатных условиях (сбой БД, сети, т.п.)?


    1. ednersky Автор
      15.02.2025 22:43

      а вот в этих ямликах можно вместо request выполнить временный shutdown одного из сервисов по имени.

      поскольку внутри теста всё выполняется "сверху вниз" и все тесты зависимы (в этом отличие от традиционных подходов), то сделать на n'том шаге факап не представляет сложности.

      но вообще это сложная тема для ЛЮБОГО подхода. в общем виде она решения не имеет (разве что каждый тест живёт в собственном кластере, но это невозможно).

      Мы такие тесты помечали, как "требующие монополии на кластер" и соответственно тестовая утилита пускала все тесты впараллель, а эти - в конце, после всех. Старались чтобы их было немного, поскольку такие вещи занимают больше всего времени.

      ещё есть вариант - injection через http-заголовки. но это вопрос большой отдельной статьи

      все вот эти варианты по вопросу что Вы задали - большая статья.


  1. Zenitchik
    15.02.2025 22:43

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

    А почему просто не дёргать библиотеку за API из своего любимого языка, безо всяких YAML?


    1. ednersky Автор
      15.02.2025 22:43

      а потому, что для чего нужны тесты? чтобы когда у тебя есть A, B, C, D и ты меняешь C, а отваливается D, то ты мог бы заглянуть и понять, что там не так. Затем прийти к разработчикам D с багом.

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

      язык тестов - язык межнационального, тьфу, межсервисного общения. Вот!


  1. SergeiZababurin
    15.02.2025 22:43

    на фронте юнит тесты могут сильно облегчить разработку.
    например.

    Есть 5 кнопок, по которым вызывается бэкенд. Ответы так связанны, что что бы проверить кнопку 5 надо нажать кнопки 1 2 3 и 4, и в каждом из этапов может возникнуть ошибка, связанная с изменением бэкенда.

    Получается.
    Исправляя и делая новый код для кнопки 5

    Нажали 1 2 3 4
    Что то дописали для 5
    Нажали 1 2 3 4
    Что то дописали для 5
    Нажали 1 2 3 4
    Что то дописали для 5
    Нажали 1 2 3 4
    Что то дописали для 5
    Нажали 1 2 3 4
    Что то дописали для 5

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

    Проще написать юнит тесст, который автоматически проходит этап нажатий, иможно сосредоточиться на разработке, а не нажатии кнопок.

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


  1. Tsegelnikov
    15.02.2025 22:43

    Кажется автор чуток путает интеграционные и e2e тесты. И если ему так нравятся тесты в yaml - то стоит обратить внимание на фреймворк от Lamoda (вроде donkey). Сам он на go, а тесты в yaml.


  1. vikarti
    15.02.2025 22:43

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


    1. SergeiZababurin
      15.02.2025 22:43

      _


  1. Dhwtj
    15.02.2025 22:43

    Примите вещества


  1. vkni
    15.02.2025 22:43

    ипа вот был у нас только Go, а теперь будет ещё и простенький Lua (или Python). А Вы попробуйте! И тут же столкнетесь с жестким сопротивлением

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


    1. qeeveex
      15.02.2025 22:43