Часть III: Простые системы — монолит как искусство
Глава 13

CI/CD для монолита — тестирование без боли

Проблема

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


Помните старую шутку?

— Как часто вы деплоите? — Раз в две недели. — А если баг в проде? — Тогда через две недели.

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

В этой главе мы разберём, как вырваться из этого круга. Как построить пайплайн, который не боится монолита, а наоборот — использует его простоту как преимущество.

Спойлер: CI/CD для монолита проще, чем для микросервисов. Просто надо делать это правильно.


#Стратегия тестирования — пирамида, не торт

Давайте сразу договоримся: если ваша тестовая стратегия похожа на слоёный торт (много E2E, мало unit) — вы в опасной зоне. Вас ждут:

  • Часы ожидания прогона тестов
  • flaky-тесты, которые падают без причины
  • CI, который занимает 40+ минут
  • И главное — ложное чувство безопасности

Правильный подход называется тестовая пирамида (test pyramid), и придумал её Майк Кон (Mike Cohn) ещё в 2009 году. Концепция простая:

        /\
       /  \         E2E (несколько, самых важных)
      /    \
     /------\
    /        \      Integration/Contract (средний слой)
   /          \
  /------------\
 /              \  Unit (основной объём)
/________________\

#Сколько каких тестов?

Классическое правило, к которому пришли опытные команды:

  • Unit-тесты — ~70% тестов. Быстрые, изолированные без внешних зависимостей.
  • Integration-тесты — ~20%. С реальными БД, API, файловой системой.
  • E2E-тесты — ~10%, а лучше 5%. Самые критичные сценарии.

Почему именно так? Потому что скорость выполнения и стоимость поддержки растут по мере продвижения вверх по пирамиде.

Для монолита это особенно важно. У вас одна кодовая база, один деплой — вы можете получить максимум профита от unit-тестов, потому что у вас нет распределённого ада с синхронизацией контрактов между сервисами. Монолит по определению проще тестировать — так используйте это.

#Анти-паттерн: пирамида наоборот

Я видел проекты, где E2E-тестов было больше, чем unit. Выглядит это примерно так: 200 E2E-тестов на Selenium/Playwright, которые идут 2 часа, падают в 30% случаев из-за таймаутов, а разработчики уже привыкли нажимать «Rerun failed tests» вместо того чтобы чинить тесты.

Друзья, это не тесты. Это ритуал.

Пирамида существует не потому, что так модно, а потому что она экономит время. Unit-тест летит за миллисекунды. Integration — за секунды. E2E — за минуты. Если ваш CI трещит по швам, первый вопрос не «как распараллелить E2E?», а «что мы тестируем E2E, хотя можно unit?».


#Unit-тесты — фундамент

Unit-тесты — первая линия обороны. Они должны быть:

  • Изолированными — никакой БД, никаких HTTP-запросов, никакой файловой системы
  • Быстрыми — тысяча тестов за пару секунд
  • Детерминированными — зелёные при каждом прогоне

#Какую логику тестировать

Фокус — на бизнес-логику. Валидация, расчёты, правила, трансформации данных.

Не надо тестировать:

  • Геттеры и сеттеры без логики
  • Простое делегирование (метод A вызывает B с теми же параметрами)
  • Конфигурацию фреймворка

Надо тестировать:

  • Правила валидации (скидка не больше 30%? проверили)
  • Статусные переходы (заказ → оплачен → отправлен)
  • Алгоритмы (расчёт налога, скоринг, сортировка)
  • Edge cases (пустая корзина, отрицательное количество, null)

#Пример: тестируем бизнес-правило

Допустим, у нас есть функция расчёта доставки:

# orders/shipping.py
def calculate_shipping_cost(order: Order, user: User) -> float:
    base_cost = 5.0

    if user.is_premium:
        return 0.0  # Бесплатная доставка для премиум

    if order.total > 50.0:
        return 0.0  # Бесплатно от 50 евро

    if order.weight > 10.0:
        base_cost += 3.0  # Тяжёлый заказ

    return base_cost

Unit-тесты должны покрывать все ветки:

# tests/unit/test_shipping.py
def test_premium_user_gets_free_shipping():
    user = User(is_premium=True)
    order = Order(total=10.0, weight=0.5)
    assert calculate_shipping_cost(order, user) == 0.0

def test_non_premium_gets_free_shipping_above_threshold():
    user = User(is_premium=False)
    order = Order(total=51.0, weight=0.5)
    assert calculate_shipping_cost(order, user) == 0.0

def test_heavy_order_incurs_surcharge():
    user = User(is_premium=False)
    order = Order(total=30.0, weight=15.0)
    assert calculate_shipping_cost(order, user) == 8.0  # 5 + 3

def test_standard_order():
    user = User(is_premium=False)
    order = Order(total=30.0, weight=5.0)
    assert calculate_shipping_cost(order, user) == 5.0

Четыре теста, четыре сценария. Каждый выполняется за миллисекунды. Если кто-то поменяет логику — тест упадёт мгновенно, до того как код доедет до code review.


#Integration-тесты — проверяем, что детали склеились

Unit-тесты проверяют изолированную логику. Integration-тесты — что всё вместе работает. База данных, API-эндпоинты, внешние сервисы (через заглушки или test containers).

#Database testing — главная боль монолита

Монолит почти всегда живёт на реляционной базе. И тестирование с базой — это отдельный вид искусства. Потому что:

  1. Состояние базы меняется между тестами
  2. Параллельные тесты создают race conditions
  3. Миграции надо проверять
  4. Тестовые данные надо чистить

Паттерн, который работает: транзакционный тест.

Каждый тест заворачиваем в транзакцию, а по окончании — откатываем. Никаких следов, никакого состояния между тестами.

# tests/integration/test_repository.py
@pytest.fixture
def db_session():
    """Создаём тестовую БД, накатываем миграции, откатываем транзакцию после теста."""
    engine = create_engine(TEST_DATABASE_URL)
    migrate(engine)  # Прогоняем миграции
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)

    yield session

    transaction.rollback()
    connection.close()
    engine.dispose()

def test_create_order(db_session):
    user = User(email="test@example.com")
    order = Order(user=user, total=100.0)

    db_session.add(user)
    db_session.add(order)
    db_session.flush()

    saved = db_session.query(Order).filter_by(user_id=user.id).first()
    assert saved is not None
    assert saved.total == 100.0

Важный момент: каждый тест начинает с чистого листа. Никакого shared state между тестами. В начале теста — накатили миграции, создали нужные данные, проверили, откатили. Всё.

#Тестирование миграций

Миграции — штука коварная. На production-базе с терабайтами данных ALTER TABLE ADD COLUMN может занять часы и заблокировать запись. А на тестовой пустой базе — пролететь за миллисекунды. И вы узнаёте об этом только когда прод лёг.

Как проверять:

  1. Тест миграции в обе стороны — накатили, откатили, проверили, что ничего не сломалось.
  2. Тест с репрезентативными данными — не одна запись, а сотня тысяч (или хотя бы профилированная выборка).
  3. Downtime-оценка — тест, который измеряет, сколько займёт выполнение миграции на реальном объёме.
# tests/integration/test_migrations.py
def test_migration_20250701_add_status_column():
    # Накатываем миграцию
    migrate_up("20250701_add_status_column")

    # Проверяем, что колонка есть
    assert column_exists("orders", "status")

    # Проверяем дефолтное значение
    order = get_test_order()
    assert order.status == "pending"

    # Откатываем
    migrate_down("20250701_add_status_column")
    assert not column_exists("orders", "status")

Бонус-совет: держите миграции идемпотентными. IF NOT EXISTS, IF EXISTS — и вы можете накатывать одну миграцию повторно без паники.

#Test fixtures и фабрики

Ещё одна боль — тестовые данные. Типичная ошибка: захардкоженные фикстуры в JSON/XML, которые никто не обновляет и которые не отражают реальную структуру базы.

Лучший подход — фабрики (factory pattern). Библиотеки вроде factory_boy (Python) или Faker генерируют реалистичные данные на лету:

# tests/factories.py
class OrderFactory(factory.Factory):
    class Meta:
        model = Order

    id = factory.Sequence(lambda n: n)
    user_id = factory.SelfAttribute("user.id")
    total = factory.Faker("pyfloat", min_value=10, max_value=1000)
    status = "pending"
    created_at = factory.Faker("date_time_this_year")

# tests/integration/test_orders.py
def test_order_workflow(db_session):
    user = UserFactory()
    db_session.add(user)

    order = OrderFactory(user=user)
    db_session.add(order)
    db_session.flush()

    order.status = "paid"
    db_session.flush()

    assert order.status == "paid"

Преимущества: данные всегда актуальны, легко менять, легко читать.

#Contract-тесты — не только для микросервисов

Контрактное тестирование обычно ассоциируется с микросервисами (когда клиент и провайдер договариваются о формате данных). Но для монолита это тоже полезно — когда у вас модули с чёткими границами.

Паттерн простой: один модуль публикует контракт (интерфейс), другой модуль тестирует, что реализация этому контракту соответствует. Pact или Spring Cloud Contract — можно, но для монолита часто хватает простого соглашения:

# contracts/order_service.py (контракт)
class OrderServiceContract:
    """Что должен уметь сервис заказов."""

    def create_order(self, user_id, items) -> Order: ...
    def cancel_order(self, order_id: int) -> Order: ...
    def get_order_history(self, user_id: int) -> List[Order]: ...

# tests/contract/test_order_service_contract.py
def test_order_service_implements_contract():
    from orders.service import OrderService

    assert hasattr(OrderService, "create_order")
    assert hasattr(OrderService, "cancel_order")
    assert hasattr(OrderService, "get_order_history")

    # Проверяем сигнатуры
    import inspect
    sig = inspect.signature(OrderService.create_order)
    assert "user_id" in sig.parameters
    assert "items" in sig.parameters

Звучит как лишняя бюрократия? Только пока у вас один модуль. А когда их десять и три команды — контракты спасают от «я думал, этот метод принимает user_id, а он оказывается user».


#E2E-тесты — минимально необходимые

E2E-тесты — это черепаха на верху пирамиды. Медленная, дорогая, но иногда незаменимая.

Для монолита E2E означает: подняли всё приложение (со всеми зависимостями — БД, кэш, очередь), запустили пользовательский сценарий от начала до конца, проверили, что бизнес-процесс работает.

Какие сценарии тестировать E2E:

  • Onboarding пользователя (регистрация → первый заказ → платёж)
  • Самый частый пользовательский путь (happy path)
  • Критическая бизнес-функция (оформление заказа, перевод денег)
  • Миграция данных

Не надо тестировать E2E:

  • Формы ввода (валидация → unit/component-тесты)
  • UI-состояния (кнопка серая → unit/component-тесты)
  • Edge cases (пользователь вводит 500 символов → unit-тесты)
  • Нечастые сценарии (отмена заказа через 2 года → integration)

#Сколько E2E-тестов достаточно?

Меньше, чем вы думаете. Вот реальный пример из проекта, в котором я участвовал:

  • Монолит на Python/Django, 200+ моделей, ~500 endpoint-ов
  • Команда: ~15 разработчиков
  • E2E-тесты: 12 штук. Покрывали 5 критических сценариев
  • CI: вся пирамида (5000 unit, 300 integration, 12 E2E) — 8 минут
  • Деплой: 15-20 раз в день

Секрет в том, что 90% багов ловятся на уровне unit и integration. E2E нужны только для того, чтобы поймать то, что эти два уровня пропустили — неправильную связку компонентов, которой нет в интеграционных тестах.


#Continuous Integration — fast feedback

CI — это сердце пайплайна. От того, как он настроен, зависит, будет ли команда деплоить без страха или будет бояться нажать кнопку «Push».

#Правила хорошего CI

1. CI должен быть быстрым.

Если тесты идут дольше 10 минут — разработчики перестают ждать. Они переключаются на другую задачу, уходят на созвон, уходят домой. Коммит висит, feedback loop растягивается до бесконечности.

Быстрый CI = быстрые тесты + параллелизация + правильная пирамида.

2. CI должен быть надёжным.

Flaky-тесты (которые падают то зелёные, то красные без изменения кода) — убивают доверие к CI. Разработчики начинают жать «rerun» вместо того чтобы чинить тест.

Правило: flaky-тест либо чинится за час, либо удаляется.

3. CI должен ловить проблемы до code review.

Пока код не прошёл CI — он не готов к review. Линтер, типизация, тесты, проверка безопасности — всё это должно отрабатывать до того, как коллега откроет PR.

#Типовой CI-пайплайн для монолита

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements-dev.txt
      - run: ruff check .
      - run: mypy .

  unit-tests:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/unit/ --cov=src --cov-report=xml
      - uses: codecov/codecov-action@v4

  integration-tests:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/integration/
      - run: pytest tests/contract/

  e2e-tests:
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: docker compose -f docker-compose.test.yml up -d
      - run: pytest tests/e2e/

#Стадии CI

Лучшая практика — fan-out пайплайн: сначала быстрое (lint), потом параллельное (unit, integration), потом медленное (E2E). Ошибка на ранней стадии отменяет остальные, экономя ресурсы.

Стадии, которые должны быть в CI:

Стадия Что проверяем Время Комментарий
Lint/Format Стайл, типы, импорты < 1 мин Блокирующая
Security Scan Уязвимости зависимостей < 2 мин Блокирующая
Unit tests Изолированная логика < 3 мин Блокирующая
Integration tests API, БД, внешние сервисы < 5 мин Блокирующая
Contract tests Контракты модулей < 2 мин Блокирующая
E2E tests Критические сценарии < 10 мин Блокирующая
Docker build Сборка образа < 3 мин Non-blocking
Performance Регрессия производительности < 10 мин Non-blocking

#Fast feedback culture

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

  • Не коммить сломанный код. Звучит банально, но на практике каждый второй делает git commit --no-verify потому что «срочно». Срочно — это когда прод горит. В остальных случаях — ждём зелёного CI.
  • Фиксим красный CI первым. В командах с дисциплиной красный CI — приоритет номер один. Не новая фича, не рефакторинг — сначала починить CI.
  • Feature flags + CI. Если фича не готова — прячем за флагом, а не откладываем коммит до завершения. Feature flags позволяют мержить незаконченный код в main, не ломая продакшен.

#Deployment strategies — доставляем без страха

Вы прошли CI, тесты зелёные. Теперь надо доставить код в продакшен. Как это сделать, не разбудив команду в 3 ночи?

#Blue/Green deployment

Два идентичных окружения: Blue (текущий продакшен) и Green (новая версия).

Before:          After:
┌──────┐         ┌──────┐
│ Blue │◄─трафик │ Blue │  <- резерв
└──────┘         └──────┘
┌──────┐         ┌──────┐
│ Green│         │ Green│◄─трафик
└──────┘         └──────┘

Переключаем трафик с Blue на Green. Если что-то пошло не так — переключаем обратно.

Плюсы:

  • Мгновенный откат (просто переключить обратно)
  • Нет смешивания версий

Минусы:

  • Нужно вдвое больше ресурсов (два полных окружения)
  • Не все состояния переносятся (сессии, кэш)

Когда годится для монолита: Когда у вас есть инфраструктура (Kubernetes, Nomad, swarm) и вы готовы платить за дублирование ресурсов.

#Rolling deployment

Обновляем экземпляры по одному. Пока один перезапускается — остальные принимают трафик.

Step 1: Step 2:    Step 3:
[A]  [A]  [A]  →  [B]  [A]  [A]  →  [B]  [B]  [A]  →  [B]  [B]  [B]

Плюсы:

  • Минимальные требования к ресурсам
  • Нет даунтайма (если правильно настроены health-checks)

Минусы:

  • В момент деплоя работают две версии кода одновременно
  • Откат — это полный rollback всех экземпляров

Проблема для монолита: Если у вас один экземпляр (всего одна копия приложения), rolling не работает. Нужно хотя бы 2-3 реплики, иначе у вас будет даунтайм при каждом рестарте.

Мораль: не держите один экземпляр монолита в проде. Даже если у вас низкая нагрузка. Два экземпляра — минимальная цена за zero-downtime deployments.

#Canary deployment

Выкатываем новую версию на небольшую часть трафика (например, 5%), смотрим метрики, если всё ок — увеличиваем до 25%, потом до 100%.

5% трафика → 25% → 50% → 100%
   ↓            ↓      ↓      ↓
  Canary     Wider   More   Full

Плюсы:

  • Минимизация воздействия плохой версии
  • Можно измерить реальное влияние на метрики (latency, error rate, revenue)
  • Не требует двойных ресурсов (если есть авторизация)

Минусы:

  • Сложнее логика роутинга
  • Нужна хорошая observability (глава 10)
  • Дольше: от начала до полного выката может пройти 30+ минут

Когда годится для монолита: Когда у вас > 3 экземпляров и хорошая observability. Для маленьких проектов — overkill.

#Что выбрать для монолита?

Вот простая таблица:

Стратегия Ресурсы Сложность Откат Когда использовать
Blue/Green 2x Средняя ⚡ Мгновенный Есть инфра, готовы платить
Rolling 1x + 1 реплика Низкая 🐢 Поэтапный Базовый zero-downtime
Canary 1x + 1 реплика Высокая ⚡ Автоматический Высокая нагрузка, опасные изменения

Мой совет для монолита: Начинайте с Rolling. Когда научитесь — переходите на Blue/Green. Canary добавляйте только если у вас трафик > 1000 RPS и вы не можете позволить себе даже минутную деградацию.


#Database migrations — слон в комнате

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

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

#Паттерн: expand-migrate-contract (EMC)

Это стандартный подход для zero-downtime миграций. Три фазы:

Фаза 1: Expand (расширение)

Добавляем новую колонку/таблицу. Старый код её не видит, новый — может писать, но не обязан.

-- migration_001_add_status.sql
ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';
-- Ничего не ломаем, дефолт есть

Деплоим код, который умеет работать с новой колонкой (опционально).

Фаза 2: Migrate (перенос данных)

Заполняем новую колонку данными из старых. Backfill-задача.

# scripts/backfill_status.py
for order in Order.objects.filter(status__isnull=True):
    order.status = determine_status(order)  # достаём из legacy-данных
    order.save(update_fields=["status"])

Может быть long-running операцией. Делаем в фоне, не блокируя продакшен.

Фаза 3: Contract (сжатие)

Удаляем старую колонку/таблицу. Теперь везде используется новая.

-- migration_003_drop_legacy_status.sql
ALTER TABLE orders DROP COLUMN legacy_status;

Весь процесс выглядит так:

Деплой 1:                    Деплой 2:                  Деплой 3:
Добавили колонку +           Залили данные +            Удалили старую
научили код писать в неё     переключили чтение         колонку
                             на новую колонку

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

#Практические правила для миграций

  • Никогда не удаляйте колонку в том же деплое, где добавили. Старое приложение может ещё работать и упадёт.
  • Добавляйте колонки с NOT NULL DEFAULT (если ваш диалект SQL позволяет), а не с nullable, иначе старые запросы сломаются.
  • Backward-compatible code wins. Если старый код может работать с новой схемой — вы в безопасности.
  • Миграции должны быть обратимыми. Каждая up() должна иметь down().

#Кейс: Как Etsy деплоит 50+ раз в день на монолит

Etsy — один из самых ярких примеров continuous deployment на монолите. PHP-монолит, который обслуживает миллионы продавцов и покупателей. И деплоят они 50+ раз в день.

#Как они это делают?

1. Deployinator.

Etsy построили собственный инструмент — Deployinator. Это не CI-система (они используют Jenkins), а интерфейс для деплоя с одной кнопкой. Разработчик нажимает «Deploy» — и код уезжает в продакшен за 5-10 минут.

Deployinator — это ответ Etsy на страх деплоя. Когда процесс выглядит как нажатие одной кнопки, команда перестаёт бояться.

2. Feature flags.

В 2010 году Etsy внедрили собственную систему фича-флагов (тогда это было редкостью). Теперь любая фича по умолчанию выключена. После деплоя разработчик включает её для себя, потом для 1% пользователей, потом для 10%, потом для всех.

Проблемы, которые это решило:

  • Нет мерж-конфликтов из-за долгих веток
  • Фичи можно отключить мгновенно, не делая rollback
  • A/B-тестирование встроено в процесс деплоя

3. Пирамида тестов.

Etsy — одни из тех, кто реально придерживается тестовой пирамиды. У них:

  • Большой слой unit-тестов (тысячи)
  • Средний слой Selenium integration test-ов
  • Минимум E2E

Главное отличие: их тесты быстрые. Они вложили много усилий в то, чтобы CI занимал не часы, а минуты. И они готовы удалять/переписывать тесты, которые становятся медленными или flaky.

4. Culture of trust.

Самое важное, что построили в Etsy — культура.

  • Любой инженер может деплоить в прод в первый день работы
  • Если деплой сломал прод — это не увольнение, а opportunity to learn
  • «Fix forward» вместо отката: если что-то пошло не так, чиним здесь и сейчас, а не откатываемся

Название их доклада на Velocity 2013 говорит само за себя: «How Etsy Deploys More Than 50 Times a Day (And Why You Should Too)».

#Что это значит для вас?

Вам не нужен Deployinator. Вам не нужно 50 деплоев в день. Вам нужно:

  • Убрать страх деплоя. Если каждый деплой — это стресс, проблема не в деплое, а в тестах, инфраструктуре или культуре.
  • Сделать деплой скучным. Деплой — не событие. Деплой — это как дышать. Вы не говорите «у меня сегодня деплой», как не говорите «у меня сегодня вдох».
  • Feature flags — не опция. Если вы не используете feature flags, вы отстали от индустрии лет на 10.

#Когда это применимо

#Стартап (1-10 разработчиков)

  • Unit + Integration. E2E минимально или нет вообще.
  • Rolling deployment. Если одна реплика — accept downtime при деплое либо поднимите вторую.
  • CI — GitHub Actions / GitLab CI. Никакого Jenkins или TeamCity — вы умрёте от настройки.
  • Feature flags — базовые. os.environ.get("FEATURE_X") — нормально для стартапа.

#Средняя компания (10-50 разработчиков)

  • Полная пирамида. Unit, Integration, Contract, E2E (10-20 штук).
  • Blue/Green или Rolling. Если бюджет позволяет — Blue/Green.
  • CI — быстрый, < 10 минут. Параллелизация обязательна.
  • Feature flags — система. LaunchDarkly, Unleash, или самописная.

#Enterprise (50+ разработчиков)

  • Contract tests обязательны. Модули от разных команд.
  • Multiple deployment strategies. Canary для критичных изменений, Rolling для обычных.
  • CI — fan-out пайплайн. Стадии с разными политиками.
  • Database migration pipeline. Отдельный пайплайн для миграций с review и тестами.

#Чек-лист для самопроверки

  • Unit-тестов значительно больше, чем E2E (70/20/10 или около того)
  • Unit-тесты не используют БД, HTTP, файловую систему
  • Integration-тесты используют тестовую БД с транзакционным откатом
  • Миграции тестируются (накат/откат) в CI
  • Flaky-тесты удаляются, а не перезапускаются
  • CI пайплайн занимает < 10 минут
  • Есть хотя бы 2 экземпляра приложения в проде
  • Деплой не требует ручных шагов
  • Feature flags используются для незаконченных фич
  • Откат деплоя проверен и документирован

#«Не делай так»

#1. Огромные E2E-тесты на всё подряд

Я видел проект (не буду показывать пальцем), где было 3000 E2E-тестов на Selenium. Они шли 6 часов. Каждый третий тест падал из-за таймаутов. Команда привыкла нажимать «Rerun failed». Через полгода они удалили 2500 тестов — и качество продукта не изменилось. Потому что 95% этих тестов проверяли то, что уже было покрыто unit-тестами.

Последствия: 6 часов CI, тонна вычислительных ресурсов, привычка игнорировать красные тесты.

#2. Единый монолитный тестовый сьют

Знаете, как выглядит CI в страшном сне? Одна команда pytest tests/ без разбивки. 30 минут. Вы не знаете, упал ли интеграционный тест или unit. Вы не знаете, какие тесты можно запустить в параллель.

Решение: Разделите тесты по директориям (unit, integration, e2e, contract) и запускайте их отдельными джобами.

#3. Деплой раз в месяц с релизным менеджером

«У нас релизный цикл — 3 недели. В пятницу — code freeze, в понедельник — релиз». Я слышу это до сих пор. В 2025 году.

Последствия: Боль накопленных изменений → конфликты мержа → стресс релиза → rollback → ещё больший стресс → выгорание команды.

Решение: Деплоить часто и маленькими порциями. Неудобно сначала, но это единственный путь к спокойной жизни.

#4. Писать миграции без обратной совместимости

Добавили колонку NOT NULL без дефолта, и старый код, который ещё живёт на production, упал при попытке вставить запись. Классика.

Последствия: P0 инцидент, срочный фикс, испорченный вечер.

Решение: Expand-Migrate-Contract. Всегда.


#Ключевые выводы

  1. Тестовая пирамида — это не теория, это бюджет времени. Unit-тесты быстрые и дешёвые — их много. E2E медленные и дорогие — их минимум.
  2. CI должен давать feedback за минуты, не за часы. Иначе разработчики перестают ждать и теряют контекст.
  3. Монолит не мешает частым деплоям. Etsy, Shopify, Stack Overflow — все деплоят монолит десятки раз в день. Всё дело в культуре и инструментах, не в архитектуре.
  4. Feature flags — суперсила монолита. Они отвязывают деплой от релиза и позволяют мержить незаконченный код без страха.
  5. Expand-Migrate-Contract — единственный способ деплоить миграции без даунтайма. Три деплоя, ни одного инцидента.

Cross-reference

Cross-reference: О том, как устроен монолит и почему с него стоит начинать — см. Главу 11 «Монолит — не грязное слово». О модульной организации монолита, которая облегчает тестирование — Главу 12 «Модульный монолит». О том, как service mesh помогает с канареечными деплоями — Главу 18 «Service Mesh и инфраструктура».

Forward reference: Как AI-агенты меняют CI/CD — см. Главу 34 «Как AI трансформирует SDLC». Verification loops на машине разработчика (+verify) и AI-assisted code review становятся частью пайплайна.