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_costUnit-тесты должны покрывать все ветки:
# 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 — главная боль монолита
Монолит почти всегда живёт на реляционной базе. И тестирование с базой — это отдельный вид искусства. Потому что:
- Состояние базы меняется между тестами
- Параллельные тесты создают race conditions
- Миграции надо проверять
- Тестовые данные надо чистить
Паттерн, который работает: транзакционный тест.
Каждый тест заворачиваем в транзакцию, а по окончании — откатываем. Никаких следов, никакого состояния между тестами.
# 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 может занять часы и заблокировать запись. А на тестовой пустой базе — пролететь за миллисекунды. И вы узнаёте об этом только когда прод лёг.
Как проверять:
- Тест миграции в обе стороны — накатили, откатили, проверили, что ничего не сломалось.
- Тест с репрезентативными данными — не одна запись, а сотня тысяч (или хотя бы профилированная выборка).
- 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. Всегда.
#Ключевые выводы
- Тестовая пирамида — это не теория, это бюджет времени. Unit-тесты быстрые и дешёвые — их много. E2E медленные и дорогие — их минимум.
- CI должен давать feedback за минуты, не за часы. Иначе разработчики перестают ждать и теряют контекст.
- Монолит не мешает частым деплоям. Etsy, Shopify, Stack Overflow — все деплоят монолит десятки раз в день. Всё дело в культуре и инструментах, не в архитектуре.
- Feature flags — суперсила монолита. Они отвязывают деплой от релиза и позволяют мержить незаконченный код без страха.
- Expand-Migrate-Contract — единственный способ деплоить миграции без даунтайма. Три деплоя, ни одного инцидента.
Cross-reference: О том, как устроен монолит и почему с него стоит начинать — см. Главу 11 «Монолит — не грязное слово». О модульной организации монолита, которая облегчает тестирование — Главу 12 «Модульный монолит». О том, как service mesh помогает с канареечными деплоями — Главу 18 «Service Mesh и инфраструктура».
Forward reference: Как AI-агенты меняют CI/CD — см. Главу 34 «Как AI трансформирует SDLC». Verification loops на машине разработчика (+verify) и AI-assisted code review становятся частью пайплайна.