У вас 90% coverage, и вас всё равно разбудили в два часа ночи.

Unit tests прошли. CI был зелёным. Баг всё равно попал в production. Coverage не соврал, но и правду не сказал. Он измерял, какие строки выполнились, а не какие поведения были реально проверены.

Большинство команд узнаёт об этом на собственном горьком опыте. Они пишут сотни unit tests, наблюдают, как badge coverage становится зелёным, и считают, что крепость неприступна. У крепости есть стены. Просто нет крыши.

Unit Tests Тестируют Только То, Что Вы Можете Себе Представить

Unit tests проверяют ваши предположения о коде. Проблема в том, что багам на ваши предположения наплевать.

Рассмотрим простую функцию ценообразования:

def calculate_total(items, tax_rate):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    return round(subtotal + tax, 2)

Типичный набор unit tests выглядит солидно:

def test_calculate_total_with_tax():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_total(items, 0.08) == 21.60

def test_calculate_total_empty_cart():
    assert calculate_total([], 0.08) == 0.00

Оба проходят. Coverage — 100%. Функция уходит в релиз.

Затем клиент в Японии оформляет заказ с тремя товарами по цене ¥100, ¥100 и ¥100. Ставка налога — 0.10. Ожидаемая сумма — ¥330. Функция возвращает ¥330.00. Нормально.

Клиент в Швейцарии покупает один товар за CHF 12.35 с НДС 7.7%. Ожидается: CHF 13.30. Фактически: CHF 13.30. Тоже нормально.

Затем клиент покупает два товара по $0.01 каждый в Орегоне, где ставка налога — 0.0. Ожидается: $0.02. Фактически: $0.02. Проходит.

Баг проявляется, когда клиент в юрисдикции со ставкой налога None (потому что tax service вернул null для неизвестного zip code) пытается оформить заказ. Функция умножает subtotal * None и выбрасывает TypeError. Ваши unit tests никогда не передавали None в качестве tax rate, потому что вы предполагали, что это всегда будет float.

Это фундаментальное ограничение. Unit tests проверяют пути, о которых вы подумали протестировать. Баги живут в путях, о которых вы не подумали.

Четыре Места, Куда Unit Tests Не Дотягиваются

Integration Boundaries

Unit tests заменяют внешние зависимости mocks. Mocks вежливы. Они делают именно то, что вы им скажете. Реальные API — не вежливы.

Ваша mock database возвращает строки за миллисекунды. Production возвращает их за секунды, или таймаутится, или возвращает дубликаты из-за lag read replica, о существовании которого вы не знали.

Ваш mock HTTP client возвращает чистый JSON. Реальный сервис возвращает 200 с пустым телом по вторникам.

Mocks тестируют ваш код против ваших предположений о других системах. Production тестирует ваш код против реальности. Это разные test suites с разными pass rates.

Stateful and Temporal Bugs

Unit tests запускаются изолированно. Каждый тест получает свежий state. Production — это долгоживущий процесс, в котором state накапливается, утекает и взаимодействует сам с собой.

Memory cache, который вытесняет записи под нагрузкой. Connection pool, который исчерпывается после 10 000 запросов. Сравнение timestamp, которое падает, когда тест проходит через границу перехода на летнее время. Эти баги требуют времени, объёма или последовательности для проявления. У unit tests ничего этого нет.

Concurrency and Race Conditions

Два пользователя одновременно обновляют одну запись. Один запрос читает баланс, другой списывает с него, первый записывает назад устаревшее значение. Деньги исчезают. Ваши unit tests запускаются последовательно в одном потоке. Они этого не поймают.

Вы можете писать unit tests для отдельных locking primitives. Вы не можете написать unit test, который докажет, что вся ваша система свободна от race conditions. State space слишком велико, а timing слишком недетерминирован.

The Environment Itself

Ваши тесты запускаются на Ubuntu 22.04 с Python 3.11, 4 ГБ RAM и без firewall rules. Production работает на Alpine Linux с Python 3.11, 512 МБ RAM и security group, которая обрывает idle TCP connections через 60 секунд.

Модуль socket ведёт себя по-разному. Лимиты mmap ниже. Настройки locale заставляют strftime форматировать даты так, как ваш parser не ожидает. Это не баги в коде. Это context bugs. У unit tests нет context.

Почему Coverage Percentage Вводит в Заблуждение

Coverage tools измеряют выполнение строк, а не качество assertions. Тест может выполнить каждую строку функции и не проверить ничего значимого.

def test_poor_coverage_quality():
    result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
    # Executed 100% of lines. Verified almost nothing.
    assert result is not None

Этот тест даёт вам 100% line coverage и ноль уверенности. Многие команды оптимизируют метрику, потому что её легко измерить. Уверенность измерить сложно. Поэтому они измеряют coverage и надеются, что эти две вещи коррелируют.

Они не коррелируют.

Что Тестировать Вместо Этого (Или В Дополнение)

Это не аргумент против unit tests. Unit tests быстры, детерминированы и отлично подходят для проверки алгоритмической логики. Они просто неполные.

Вот что закрывает пробелы, не превращая ваш CI pipeline в 45-минутную обузу.

Тестируйте на System Boundaries, А Не Только Internals

Вместо того чтобы мокать database, пишите тесты, которые бьются в реальную test database. Они медленнее, поэтому запускайте их выборочно. Но они ловят несоответствие между вашими ORM queries и реальным поведением query planner.

Вместо того чтобы мокать HTTP client, поднимайте downstream service в контейнере. Это ловит schema drift, поведение при таймаутах и retry logic, которая срабатывает только при реальных connection failures.

Добавьте Contract Tests для External Services

Если вы не можете запускать реальную зависимость в CI, используйте contract tests. Они проверяют, что ожидания consumer совпадают с реальной схемой API provider.

Инструменты вроде Pact записывают взаимодействия между вашим сервисом и его зависимостями. Если provider меняет тип поля или удаляет endpoint, contract test падает до того, как код задеплоится. Это не так хорошо, как integration testing, но гораздо лучше, чем надеяться, что ваши mocks точны.

Используйте Property-Based Testing для Edge Cases

Инструменты property-based testing вроде Hypothesis (Python) или fast-check (JavaScript) генерируют тысячи случайных inputs и проверяют, что ваши invariants соблюдаются.

from hypothesis import given, strategies as st

@given(
    st.lists(st.fixed_dictionaries({
        "price": st.decimals(min_value=0, max_value=10000, places=2),
        "quantity": st.integers(min_value=0, max_value=1000)
    })),
    st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
    if tax_rate is None:
        with pytest.raises(TypeError):
            calculate_total(items, tax_rate)
        return

    result = calculate_total(items, tax_rate)
    assert result >= 0
    assert result == round(result, 2)

Этот тест поймал бы баг с None tax rate без необходимости думать об этом конкретном случае. Он генерирует inputs, о которых вы бы никогда не подумали: пустые списки, гигантские списки, нулевые цены, decimals максимальной точности. Он находит границы вашей логики, не требуя от вас представлять их заранее.

Мониторьте Production Как Test Environment

Самый честный test suite — это production traffic. Если вы не можете поймать баг до релиза, поймайте его до того, как он причинит вред.

Используйте feature flags, чтобы раскатывать изменения сначала на 1% пользователей. Следите за error rates, latency percentiles и business metrics. Unit test говорит вам, ведёт ли себя код как ожидается в изоляции. Production monitor говорит вам, ведёт ли себя код как ожидается в реальности.

Настройте alerts на аномалии, а не только на жёсткие ошибки. Увеличение 500 errors на 5% после деплоя — часто единственный сигнал о том, что race condition или resource leak начались. Unit tests никогда не покажут вам этого.

Честный Компромисс

Unit tests дёшевы, быстры и хороши для developer feedback loops. Integration tests дороги, медленны и хороши для ловли багов, которые имеют значение.

Вам нужны и те, и другие. Ловушка в том, чтобы думать, что 100% unit test coverage означает, что можно пропустить всё остальное. Это означает, что вы тщательно протестировали простые части. Сложные части, те, что будят вас ночью, живут там, куда ваши тесты не смотрят.

Начинайте с unit tests для логики и алгоритмов. Добавляйте integration tests на каждой system boundary. Используйте property-based testing, чтобы найти inputs, о которых вы не думали. Мониторьте production, чтобы поймать то, что пропустил каждый тест.

Coverage — это vanity metric. Единственная метрика, которая имеет значение, — спите ли вы всю ночь.


Если вы пытаетесь поймать баги, которые пропускают ваши тесты, начните с просмотра ваших данных об ошибках. Sentry покажет вам, что ломается в production, со stack traces и context, которых у ваших unit tests никогда не было.