open atlas
↑ К треку
CI/CD-пайплайны CICD · 02 · 03

Контрактное тестирование и борьба с flaky-тестами

Независимо деплоящиеся сервисы ломают друг друга через API; consumer-driven contract ловит это до деплоя без общего интеграционного окружения. А flaky-тесты подрывают доверие, пока green не перестанет значить «безопасно мёржить» — делай их детерминированными, не retry.

CICD Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Команда checkout переименовывает JSON-поле total_cents в amount_cents и катит в 11:00. Их тесты зелёные, сервис здоров. В 11:04 сервис orders начинает падать — он всё ещё читает total_cents, теперь undefined, и пишет NaN в реестр. Никто не гонял тест, который проверял бы оба сервиса вместе, потому что единственный такой жил в ночном интеграционном прогоне на 40 минут, который падает так часто, что письмо от него все игнорируют. Здесь два отдельных провала CI: не было быстрой проверки, что orders зависит от этого поля, а единственная медленная проверка, которая могла бы поймать, давно потеряла всё доверие из-за flaky.

Почему общее интеграционное окружение — не тот инструмент

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

Когда два сервиса деплоятся одним релизным поездом, end-to-end тест, поднимающий оба, честен: он тестирует ровно то, что едет в прод. В тот момент, когда они начинают деплоиться независимо — нормальное состояние любого парка микросервисов — эта честность испаряется. Полноценное интеграционное окружение должно держать все сервисы разом, что делает его медленным в провижининге, дорогим в поддержании актуальности и магнитом для flaky: любой из N сервисов в процессе деплоя валит прогон по причинам, не связанным с твоим изменением. Хуже того — оно тестирует не тот вопрос. Тебе не важно, совместимы ли все сервисы между собой в каком-то снапшоте staging; тебе важно, не сломает ли версия, которую ты вот-вот задеплоишь, тех, кто от неё зависит, и не сломают ли её те, от кого зависит она.

Consumer-driven contract testing (тестирование на основе контрактов, продиктованных потребителем) отвечает ровно на этот вопрос, ни разу не запуская два сервиса в одном процессе. Идея: единственная часть API провайдера, которая имеет значение, — та, которой реально пользуются его consumer’ы. Так пусть каждый consumer запишет, как побочный продукт собственных unit-тестов, конкретные пары запрос/ответ, на которые он опирается, — эта запись и есть contract. Каждая пара запрос/ответ — это одна interaction: «когда я делаю GET /orders/42, я жду 200 с телом, где amount_cents — число». Тесты consumer’а гоняются против mock-провайдера, который проигрывает эти ответы, поэтому остаются быстрыми и изолированными. Затем provider забирает contract каждого consumer’а и в своём собственном pipeline проверяет, что может удовлетворить каждую interaction против настоящего хендлера. Переименование в checkout выше провалило бы provider-верификацию checkout’а в тот же миг, как прогнало бы contract orders’а — в собственном CI checkout’а, до мёржа, без единого задеплоенного где-либо orders.

Broker и can-i-deploy

Деталь, превращающая два pipeline’а в скоординированный gate, — это broker (Pact Broker или его хостед-форма PactFlow). Consumer публикует туда свой contract, помеченный своей версией и веткой/окружением, куда он направляется. Pipeline провайдера тянет нужные contract’ы из broker’а, верифицирует их и публикует результат верификации обратно. Так broker держит матрицу: для каждой версии consumer’а и версии провайдера — проверено ли, что эта пара совместима?

Эта матрица и питает can-i-deploy — собственно deploy-gate. Прежде чем выкатить версию X сервиса в окружение, ты спрашиваешь broker can-i-deploy --pacticipant orders --version X --to-environment production, и он отвечает «да» только если у каждого сервиса, с которым orders будет общаться в проде, есть записанная, прошедшая верификация против contract’а orders на версии X (и наоборот). Это запрос по уже собранным результатам, а не свежий прогон тестов, поэтому он мгновенный. В этом и разница с общим окружением: вместо того чтобы надеяться, что снапшот staging отражает прод, broker знает, какие именно версии совместимы, потому что каждая сторона доказала это в своём быстром pipeline.

Contract-тесты — не замена schema- или OpenAPI-проверок: они отвечают на более узкий, острый вопрос. OpenAPI-diff говорит, что спека изменилась ломающим образом; contract говорит, ломает ли это изменение реального consumer’а. Переименование поля, которое никто не читает, — ломающее изменение схемы, но не бьёт ни одного contract’а, поэтому деплоится свободно. Ужесточение поля, которое парсят три consumer’а, проходит схему (всё ещё валидный JSON), но валит их contract’ы. Используй schema-линтинг как дешёвый первый фильтр; используй contract’ы, чтобы гейтить по реальной связанности.

Почему это работает

Contract testing — социотехническая штука, а не просто инструмент. Contract — это договорённость между двумя командами, поэтому provider, который «чинит» проваленную верификацию, тихо отредактировав contract consumer’а, обнулил весь смысл — contract должен принадлежать consumer’у и меняться только по согласованию. Bi-directional contract testing (PactFlow) ослабляет это, позволяя провайдеру дать свою OpenAPI-спеку вместо прогона тестов, сгенерированных consumer’ом, обменивая часть точности на меньшую связанность тест-сьютов команд.

Flaky-тесты: убийца доверия

Как выглядит пайплайн, которым завладела flakiness? Ровно как в хуке: ночной прогон, который падает так часто, что письма от него никто не читает, и две команды деплоятся независимо без общего сигнала. Это состояние обратимо — но только если относиться к flaky как к проблеме качества с владельцем, а не как к фоновому шуму.

Flaky-тест проходит и падает на одном и том же коде без изменений между прогонами. Он опаснее стабильно красного теста, потому что red, который ничего не значит, приучает инженеров игнорировать red. Как только «да просто перезапусти» становится рефлексом, твой pipeline перестаёт быть gate’ом: реальный регресс прячется в шуме, люди мёржат по наитию, и вся ценность CI — что green значит безопасно мёржить — пропадает. Относиться к flaky как к самостоятельной проблеме качества, с бюджетом и владельцем, — это сеньорский ход; терпеть её — это как тест-сьют умирает.

Причины — короткий повторяющийся список, и название причины диктует фикс.

ПричинаСимптомДетерминированный фикс
Тайминг / raceПадает под нагрузкой CI, проходит локально; sleep(100) «чинит»Жди условие (поллинг элемента/состояния), никогда не фиксированный sleep; контролируй порядок async
Реальные часы / датаЛомается в полночь, в конце месяца или на переходе DSTВнедри fake clock; заморозь время в тесте
Общее состояние / зависимость от порядкаПроходит в одиночку, падает при перемешивании сьюта или параллельном прогонеИзолируй состояние на тест (свежая БД/rollback транзакции); рандомизируй порядок, чтобы вскрыть это
Реальная сеть / внешний сервисПадает, когда третья сторона медленна или лежитЗамокай границу; вынеси интеграцию в contract-тест
Загрязнение тестовТест B падает только после теста A (утёкший global/singleton/файл)Сбрасывай global’ы в teardown; никаких кросс-тестовых фикстур

Воркфлоу вокруг них — три хода. Detect: перезапускай сьют по расписанию и трекай pass/fail по каждому тесту во времени, чтобы flaky показывалась дашбордом, а не ощущением. Quarantine: когда тест флакает, помечай его как quarantine, чтобы он всё ещё гонялся и репортил, но не гейтил мёрж, — и заводи тикет. Это защищает сигнал pipeline’а, пока ты чинишь тест, без худшей альтернативы — удалить его. Fix: дотащи его до детерминизма по таблице выше; quarantine — это карантин, а не кладбище, и список quarantine, который только растёт, сам по себе провал.

Вместе detect → quarantine → fix — единственный цикл, который реально сжимает flake-count; пропуск «fix» означает, что список карантина растёт, пока ты тихо не потерял покрытие половины кодовой базы.

Bounded retry — самый злоупотребляемый инструмент здесь. Авто-перезапуск упавшего теста, пока он не пройдёт, не чинит flaky — он её прячет, и спрячет реальный перемежающийся баг (настоящий race в проде) так же охотно. Единственное оправданное применение — маленький, ограниченный retry (например, 2 попытки) только на end-to-end тестах, где часть средовой flaky неустранима, и только когда каждый retry логируется и считается, чтобы flake-rate оставался видимым. Никогда не делай retry unit- или интеграционного теста: они должны быть детерминированными, и retry там — это признание, что ты пропустил фикс.

Выбери лучший вариант

Unit-тест калькулятора скидки на заказ проходит ~95% прогонов CI и падает в остальных, на неизменном коде. Что делаешь?

Викторина

Provider переименовывает JSON-поле, которое читает один consumer. Какая проверка вероятнее всего поймает это до деплоя и почему?

Викторина

Unit-тест падает ~1 раз из 20 на неизменном коде. Коллега вешает авто-retry 3x, чтобы билд позеленел. В чём сеньорское возражение?

Вспомните перед уходом
  1. 01
    Пройди consumer-driven contract testing от начала до конца и скажи, почему он лучше общего интеграционного окружения.
  2. 02
    Чем опасны flaky-тесты и какова правильная реакция — включая, когда retry допустимы?
Итог

Независимо деплоящиеся сервисы ломают друг друга через API, и полное интеграционное окружение — не тот инструмент, чтобы это ловить: медленное, склонное к flaky, и оно тестирует, совместим ли какой-то снапшот staging между собой, а не безопасна ли версия, которую ты вот-вот отгрузишь. Consumer-driven contract testing отвечает на острый вопрос вместо этого — каждый consumer записывает пары запрос/ответ, которые реально использует, как contract, гоняет свои тесты против mock’а и публикует contract в broker; provider тянет каждый contract и верифицирует его против настоящего хендлера в своём pipeline, публикуя результат обратно. Матрица верификации broker’а питает can-i-deploy, мгновенный запрос, который гейтит релиз только когда каждая релевантная пара доказанно совместима. Contract’ы дополняют schema/OpenAPI-линтинг: схема говорит, что спека изменилась, contract — ломает ли это реального consumer’а. Вторая половина — доверие: flaky-тест, который проходит и падает на одном коде, приучает людей игнорировать red, и как только «да просто перезапусти» становится рефлексом, green больше не значит «безопасно мёржить». Детектируй flaky прогонами по расписанию и дашбордом, отправляй flaky-тест в quarantine, чтобы он всё ещё репортил, но не гейтил, пока заводишь тикет и чинишь, и делай его детерминированным, убивая причину — fake clock, изолированное состояние, контролируемый порядок, замоканные границы. Резервируй bounded retry только для end-to-end тестов, всегда с логом; retry на unit-тесте лишь прячет устранимый дефект и может спрятать реальный баг вместе с ним. Теперь, когда переименование поля сломает downstream-сервис в 11:04, ты будешь знать, чего не хватало: contract, который поймал бы это в CI checkout’а — до мёржа, до деплоя.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.