Инженерная практика
Дилемма интеграционного тестирования
В 02:14 сервис orders начинает отдавать 500-е. Команда провайдера переименовала поле с total_cents на amount_cents и выкатила это — чистое, хорошо протестированное изменение, зелёное на их собственном наборе тестов. Ничто в их CI не знало, что три нижестоящих консьюмера читают total_cents. У команды было «полноценное интеграционное окружение», но оно было красным уже девять дней, и все перестали в него смотреть. Первым реальным сигналом для кого-либо стал пейджер, в проде, в пятницу. Фикс занял десять минут. Узнать, что оно сломалось, стоило аварии — потому что единственный тест, который мог это поймать, был тем, которому уже никто не доверял.
Интуитивный ответ не масштабируется
Спросите «будут ли эти два сервиса по-прежнему общаться?» — и очевидный ответ: запусти оба, отправь реальный запрос, проверь ответ. Этот инстинкт верен — для двух сервисов. Беда в том, что происходит по мере роста системы. При N сервисах, вызывающих друг друга, число пар взаимодействий, которые хотелось бы прогнать, растёт примерно как N², а end-to-end-тест любого одного сценария требует, чтобы каждый хоп в этом сценарии был здоров в один и тот же момент. Запрос, расходящийся через gateway → orders → pricing → inventory → payments, проходит свой e2e-тест только когда все пять сервисов, плюс их базы данных и очереди, одновременно подняты, промигрированы и засеяны правильными данными.
Требование «все здоровы одновременно» — это тихий убийца. У каждого сервиса, скажем, 98% шанс быть зелёным в общем окружении в любой момент. Свяжите пять вместе — и сценарий зелёный лишь ~90% времени; свяжите двенадцать — и вы уже ниже 80%. Окружение сломано не из-за вашего изменения — оно сломано, потому что чужая миграция применена наполовину, или провалился seed-скрипт, или зависимость в середине деплоя. Весь этот шум вы наследуете на каждом прогоне.
Медленный, флакающий — а значит, игнорируемый
Эти две силы — комбинаторная подготовка и общая хрупкость — порождают три симптома, узнаваемых любой командой на большом e2e-наборе. Медленный: поднять полдюжины сервисов, базу и брокер сообщений до первого ассерта толкает наборы к 40+ минутам, так что они уходят из внутреннего цикла в ночной джоб, за которым никто не следит. Флакающий: поскольку любой хоп может отвалиться по таймауту, большая доля падений — часто называют около 1 из 5 прогонов — не имеет отношения к проверяемому изменению. Игнорируемый: тест, кричащий «волки» четыре раза на каждый реальный баг, глушат, перезапускают-до-зелёного или карантинят. Заглушенный тест не ловит ничего, что строго хуже отсутствия теста, потому что он всё ещё стоит вам времени прогона и даёт ложное спокойствие.
Именно поэтому зрелые платформы переворачивают классический совет. Netflix и Spotify знаменито перекроили «пирамиду» тестов в соты или ромб на уровне сервисов: тонкая шапка настоящих end-to-end-сценариев (ориентир — примерно 5-10% всех тестов), а основная масса уверенности в межсервисной совместимости спущена во что-то более быстрое и изолированное. Вопрос, на который отвечает весь этот юнит: что это за более быстрая и изолированная штука?
| Слой тестов | Что поднимает | Скорость / детерминизм | Ловит переименование в 02:14? |
|---|---|---|---|
| Unit-тест | Ничего — граница замокана | Миллисекунды, детерминирован | Нет — мок возвращает старую форму |
| End-to-end | Все сервисы + БД + очередь вместе | Минуты, флакает на любом хопе | Да — если окружение зелёное, а оно нет |
| Недостающий слой | Одна сторона, против записанного соглашения | Секунды, детерминирован | Да — и прямо за столом автора |
Граница — именно там, где в пирамиде дыра
Вот тонкий момент. Классическая пирамида тестов гласит: «много unit-тестов, меньше интеграционных, очень мало e2e». Внутри монолита это работает, потому что unit-тест прогоняет реальные внутрипроцессные вызовы между модулями. Через сетевую границу появляется течь: unit-тест консьюмера orders мокает провайдера pricing. Мок возвращает ту форму, которую автор консьюмера считал, что отдаёт pricing. В день, когда pricing переименовывает поле, unit-тесты консьюмера остаются зелёными — они проверяют устаревшее убеждение автора, а не реальность. То, что вероятнее всего сломается (контракт по проводу между сервисами), — это ровно то, что unit-тесты намеренно заглушают.
Так что вас сжимает с двух сторон. Unit-тесты быстры и надёжны, но слепы к границе по построению. End-to-end-тесты могут видеть границу, но слишком медленны и флакающи, чтобы гейтить каждый деплой. Авария с переименованием поля проваливается прямо в эту щель. Нужен тест, который проверяет именно границу — форму и семантику запросов и ответов, которыми обмениваются два сервиса, — не поднимая оба сервиса вместе. Такова форма задачи, которую решает остальной юнит.
Почему это работает
«Просто держите интеграционное окружение зелёным» звучит как проблема дисциплины, но это проблема структурная. Аптайм общего окружения — это произведение индивидуальных аптаймов всех сервисов, поэтому он деградирует мультипликативно по мере добавления сервисов, а его здоровье принадлежит всем, что означает — никому. Призыв «постарайтесь сильнее» не меняет математику. Единственный долговечный фикс — перестать требовать, чтобы все сервисы были одновременно здоровы, лишь бы узнать, согласны ли два из них.
На платформе 18 сервисов с плотными HTTP-зависимостями. E2e-набор идёт 45 минут и падает на ~20% прогонов по причинам, не связанным с изменением. Команда хочет надёжную обратную связь по межсервисной совместимости. Какое направление наиболее здравое?
Почему надёжность end-to-end-набора деградирует по мере добавления сервисов в сценарий?
Почему unit-тесты не ловят переименование провайдером поля, которое читает консьюмер?
Упорядочьте, как стратегия «только e2e» деградирует до аварии в 02:14:
- 1 Два сервиса интегрируются; ради их проверки поднимают общее e2e-окружение
- 2 Присоединяется больше сервисов; сценарию нужны они все здоровы одновременно
- 3 Набор замедляется за 40 минут и флакает ~1 из 5 по несвязанным причинам
- 4 Команда глушит окружение или перестаёт следить; оно красное днями
- 5 Провайдер переименовывает поле; единственный тест, что поймал бы это, заглушен; прод пейджит в 02:14
- 01Коллега говорит: «нашему интеграционному окружению просто нужно больше дисциплины, чтобы оставаться зелёным». Объясните, почему это структурная проблема, а не дисциплинарная.
- 02Где именно пирамида тестов «ломается» на границе сервиса и почему это пропускает переименование поля в прод?
Инстинкт тестировать интеграцию, запуская все сервисы вместе, верен для двух сервисов и неверен для двадцати. End-to-end-наборы растут комбинаторно: при N взаимодействующих сервисах число пар для прогона масштабируется как N², а любой сценарий проходит, только когда каждый хоп здоров в один и тот же миг, так что надёжность общего окружения — произведение его частей и деградирует мультипликативно по мере добавления сервисов. Результат — знакомая троица: медленно (подъём за 40+ минут), флакающе (~1 из 5 падений не связаны с изменением) и потому заглушено, а заглушенный тест не ловит ничего, при этом всё равно стоит времени прогона. Классическая пирамида не спасает, потому что на сетевой границе unit-тесты мокают провайдера и проверяют устаревшее убеждение автора о его форме, оставаясь зелёными в день, когда провайдер переименовывает поле. Так вас сжимает между слоем «быстрый-но-слепой» и слоем «видит-но-не-доверяют», и межсервисное переименование проваливается в щель — в пейджер в 2 ночи. Нужен слой, который проверяет саму границу — форму и семантику, которыми обмениваются два сервиса, — не требуя поднимать оба вместе. Этот слой — contract testing, и его выстраивание — то, чем занимается остальной юнит.