Набор тестов в реальном проекте редко проектируют осознанно. Он нарастает сам, тест за тестом, и однажды команда обнаруживает, что сборка идёт сорок минут, падает через раз, а исправление одной строки требует получасового ожидания вердикта. Корень почти всегда один: перекос в сторону медленных сквозных тестов при тощем основании быстрых модульных. Пирамида тестов это не академическая схема, а ответ на вполне конкретную боль, которая возникла именно потому, что команды раз за разом упирались в медленные, нестабильные и дорогие в сопровождении наборы.

Идея пирамиды проста до банальности. Тестов должно быть много внизу, где они быстры, дёшевы и точно указывают на сломавшуюся строку, умеренно посередине, где проверяются связи между частями, и совсем мало вверху, где проверяется вся система глазами пользователя. Форма именно пирамидальная, с широким основанием, потому что чем ниже тест, тем дешевле он в прогоне и сопровождении, и тем быстрее даёт обратную связь. Перевернуть пирамиду означает платить высшую цену за большую часть сигнала.

Дальше идёт разбор того, какое соотношение слоёв работает в реальном проекте, где проходит граница между типами тестов, как удержать прогон сборки коротким и какие антипаттерны душат команды. Кода ровно столько, сколько нужно увидеть разницу слоёв, и весь он в отдельных блоках с разбором вокруг.

Три слоя пирамиды и разный вопрос, на который отвечает каждый

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

// модульный тест: верна ли конкретная функция
test('скидка для золотого клиента 20 процентов', () => {
  expect(priceFor(1000, 'gold')).toBe(800)
})

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

// интеграционный тест: верно ли стыкуются код и база
test('создание пользователя сохраняет запись в базу', async () => {
  await userService.create({ name: 'Ида' })
  const saved = await db.users.findByName('Ида')
  expect(saved).toBeDefined()
})

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

Соотношение слоёв в реальном проекте и почему нет волшебного числа

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

Любопытный сдвиг наметился с распространением генерации кода искусственным интеллектом. Команды, активно использующие такие инструменты, обнаруживают, что соотношение со сдвигом к интеграционным и сквозным тестам, скажем шестьдесят на двадцать пять на пятнадцать, а для ответственных приложений и пятьдесят на тридцать на двадцать, даёт меньше инцидентов, чем классическое. Причина в том, что расширенные слои интеграции и сквозных тестов ловят именно тот класс отказов, который сгенерированный код порождает чаще всего: правдоподобную на вид логику, которая ломается на стыках и в реальных сценариях. Это не отменяет пирамиду, а лишь подстраивает её пропорции под новый источник риска.

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

Где именно проходит граница между типами тестов

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

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

Золотое правило размещения теста звучит так: каждое поведение проверяется один раз, на самом низком уровне, на котором это осмысленно. Если логику можно проверить модульным тестом, не нужно дублировать её ещё и в интеграционном, и в сквозном. Дублирование одного и того же поведения на всех слоях тихо взрывает стоимость сопровождения, потому что любое изменение приходится отражать в нескольких местах.

Антипаттерны, которые превращают пирамиду в обузу

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

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

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

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

Как измерить здоровье пирамиды и заметить перекос вовремя

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

Первая метрика это распределение числа тестов по слоям. Если доля сквозных тестов растёт от релиза к релизу, а доля модульных проседает, пирамида медленно переворачивается, и это видно задолго до того, как сборка начнёт мучить команду. Вторая метрика это вклад каждого слоя в общее время прогона. Бывает, что сквозных тестов по числу немного, но они съедают восемьдесят процентов времени сборки, и тогда именно они кандидаты на сокращение или перенос в ночной прогон. Третья метрика это нестабильность по слоям: если падения сосредоточены наверху, это лишний довод спустить проверки вниз.

# пример здоровой сводки по слоям
слой           число тестов   доля времени   нестабильность
модульные      1840 (72%)     18%            0.0%
интеграционные  490 (19%)     34%            0.3%
сквозные        230 (9%)      48%            1.1%

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

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

Как держать прогон сборки коротким и встроить слои в пайплайн

Практическая цель, к которой стоит стремиться, это удерживать полный прогон в разумных пределах, в идеале сборку под десять минут. Достигается это не отказом от тестов, а правильным распределением их по моментам пайплайна. Быстрые модульные тесты гоняют на каждый коммит, давая мгновенную обратную связь. Интеграционные тесты прогоняют на слияниях или сборках, проверяя стыки перед попаданием кода в общую ветку. Сквозные тесты запускают выборочно, на критичных сценариях, перед релизами, а полный их набор гоняют по ночам, когда время не поджимает.

# распределение слоёв по моментам пайплайна
on_commit:   [unit]              # мгновенно, на каждый коммит
on_merge:    [unit, integration] # на слияние в общую ветку
nightly:     [unit, integration, e2e]  # полный прогон по ночам
pre_release: [e2e_critical]      # критичные сценарии перед релизом

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

Чтобы свести стратегию к рабочим ориентирам, ниже единственный список этой статьи, расставленный по важности:

  1. Держать форму пирамиды с широким основанием быстрых модульных тестов и узкой вершиной сквозных, не гоняясь за точными процентами;
  2. Проверять каждое поведение один раз на самом низком разумном уровне, избегая дублирования одной проверки на всех слоях;
  3. Различать контрактное и пользовательское: эндпоинт с ответом в формате JSON это интеграция, нажатие кнопки с видимым результатом это сквозной тест;
  4. Распределять слои по пайплайну: модульные на коммит, интеграционные на слияние, сквозные выборочно перед релизом и полным набором по ночам;
  5. Мокать только границы, держа интеграционные тесты близко к настоящим зависимостям через контейнеры, чтобы тесты не лгали;
  6. При активной генерации кода искусственным интеллектом усиливать слои интеграции и сквозных тестов, потому что именно там всплывают типичные для него отказы.

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

Итог укладывается в одну мысль. Набор тестов либо ускоряет команду, либо душит её, и решает это форма. Широкое основание быстрых дешёвых модульных тестов даёт мгновенный точный сигнал на каждый коммит, умеренный слой интеграционных проверяет стыки с реальными зависимостями, а тонкая вершина сквозных подтверждает критичные пользовательские сценарии перед релизом. Перевёрнутая пирамида, рожок мороженого из массы сквозных тестов, оборачивается медленным пайплайном, хрупкими сборками и деморализованной командой, ждущей вердикт дольше, чем пишущей код. Точные проценты вторичны, и они даже сдвигаются в эпоху сгенерированного кода. Первична форма и принцип: проверять каждое поведение один раз, на самом низком разумном уровне, мокать только границы и распределять слои по пайплайну так, чтобы обратная связь оставалась короткой. Команда, держащая пирамиду пирамидой, движется быстро. Команда, перевернувшая её, платит высшую цену за каждую галочку.