Антипаттерны: тесты на реализацию и флаки

Тесты, которые приносят больше вреда, чем пользы, и как их распознать.

Флаки-тест (flaky) — это тест, который на неизменном коде то проходит, то падает; тест на реализацию проверяет как устроен код, а не что он делает.

Антипаттерн 1: тесты на детали реализации

Такой тест привязан к внутреннему устройству: «функция использует именно цикл», «вызывает метод X дважды». Стоит безобидно переписать реализацию — тест краснеет, хотя поведение не изменилось. В итоге тесты мешают рефакторингу вместо того, чтобы его защищать.

Правило: проверяйте наблюдаемое поведение (вход → выход), а не внутренние шаги.

# Две реализации — поведение одинаковое
def sum_v1(nums):
    total = 0
    for n in nums:
        total += n
    return total

def sum_v2(nums):
    return sum(nums)   # рефакторинг: другой способ, тот же результат


# Хороший тест проверяет ПОВЕДЕНИЕ, поэтому проходит на обеих
for impl in (sum_v1, sum_v2):
    assert impl([1, 2, 3]) == 6
    assert impl([]) == 0
print("Тест на поведение переживает рефакторинг — обе реализации зелёные")

Вывод:

Тест на поведение переживает рефакторинг — обе реализации зелёные

Антипаттерн 2: флаки-тесты

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

Причина флакиЛечение
Зависимость от времени/датыПередавать время снаружи, фиксировать
Случайность без seedЗадавать seed
Зависимость от порядка тестовИзолировать состояние
Сеть/внешние сервисыИспользовать дублёры (stub/fake)
Гонки и таймеры (sleep)Ждать условие, а не фиксированное время

Демонстрация нестабильности и её устранения

import random

# ПЛОХО (концептуально): результат зависит от неуправляемой случайности
# -> иногда проходит, иногда нет. Не делаем так.

# ХОРОШО: фиксируем seed -> тест детерминирован
def pick(seed):
    rnd = random.Random(seed)
    return rnd.choice(["A", "B", "C"])

# Один и тот же seed всегда даёт один результат -> не флаки
assert pick(1) == pick(1)
assert pick(7) == pick(7)
print("Случайность зафиксирована seed — тест больше не флакает")

Вывод:

Случайность зафиксирована seed — тест больше не флакает

Итог

  • Тесты на реализацию мешают рефакторингу — проверяйте поведение, а не устройство.
  • Флаки-тесты подрывают доверие ко всему набору — их нужно чинить или удалять.
  • Источники флаки — время, случайность, порядок, сеть; лечатся фиксацией и дублёрами.
Проверьте себя
1. Чем плох тест на детали реализации?
AОн слишком быстрый
BОн ломается при рефакторинге, даже когда поведение не изменилось
CОн не использует assert
DОн проверяет результат
2. Почему флаки-тесты особенно опасны для команды?
AОни занимают много места
BКоманда привыкает к красному и начинает игнорировать настоящие падения
CОни слишком надёжны
DИх нельзя запустить в CI
3. Как побороть флаки-тест, зависящий от случайности?
AЗапускать его много раз и надеяться
BЗафиксировать источник случайности (задать seed)
CУдалить все assert
DДобавить sleep подольше
Поддержать проект