Детерминированные тесты в Python: время, случайность, сеть
Тест, который проходит десять раз подряд и падает на одиннадцатый, — не флейки, а незамороженные datetime, random без seed и живые HTTP-запросы. Разбираем, чем изолировать каждый источник недетерминизма.

Откуда берётся тест, который «иногда падает»
Тест зелёный локально, падает в CI по пятницам, проходит после перезапуска — знакомая картина. В 90% случаев причина одна из трёх: код читает текущее время, дёргает генератор случайных чисел без фиксированного зерна или ходит в сеть. Все три источника недетерминизма изолируются штатными средствами Python и парой библиотек, но каждый требует своего подхода. Ниже — как закрыть каждый, чем платит каждый способ и где он ломается.
Материал для тех, кто уже пишет тесты на pytest (версии 7.x–8.x) и упёрся в нестабильность. Код проверялся на CPython 3.11 и 3.12.
Что понадобится по коду
- Python 3.11+ (для примеров с
zoneinfoи современнымdatetime); - pytest 7.x или 8.x;
freezegunдля заморозки времени (илиtime-machineкак более быстрая альтернатива);responsesлибоrespxдля моков HTTP — что выбрать, зависит от клиента.
Время: не мокайте datetime напрямую
Первая ошибка — попытка подменить datetime.datetime.now через unittest.mock.patch. Это работает нестабильно: datetime — C-тип, и патч попадает не туда, где его вызывает ваш код. Проблема не в мокинге, а в том, что вы патчите не тот символ. Патчить нужно там, где импортирован объект, а не там, где он определён.
Практичнее взять freezegun. Он подменяет само время процесса, и все обращения к datetime.now(), date.today(), time.time() возвращают фиксированное значение.
import datetime
from freezegun import freeze_time
# код под тестом
def is_business_day():
today = datetime.date.today()
# понедельник=0 ... воскресенье=6
return today.weekday() < 5
@freeze_time("2024-06-15") # суббота
def test_saturday_is_not_business_day():
assert is_business_day() is False
@freeze_time("2024-06-17") # понедельник
def test_monday_is_business_day():
assert is_business_day() is TrueЗаморозка работает и как контекстный менеджер, и как фикстура. Если время должно «идти» внутри теста, используйте объект тика:
from freezegun import freeze_time
import time
def test_time_advances():
with freeze_time("2024-01-01 12:00:00") as frozen:
start = time.time()
frozen.tick() # по умолчанию сдвиг на 1 секунду
assert time.time() - start == 1.0Где заморозка ломается
freezegun замораживает Python-уровень. Он не влияет на время внутри базы данных: если ваш SQL пишет NOW() или колонка имеет DEFAULT now(), заморозка Python на это не действует. Значение проставит СУБД своим временем. То же с внешними сервисами и с C-расширениями, которые берут время в обход стандартной библиотеки.
Второй подводный камень — производительность. freezegun обходит модули и подменяет ссылки; на больших наборах тестов это заметно. Если заморозка нужна в тысячах тестов, посмотрите на time-machine — она работает через хук на уровне C и быстрее, но имеет чуть другой API.
Часовые пояса и «наивные» datetime
Даже с замороженным временем тест валится при смене таймзоны раннера. Причина — datetime.now() без аргумента возвращает наивный объект в локальной зоне сервера. В CI зона обычно UTC, на ноутбуке разработчика — местная. Разница ловится на границах суток.
Правило, которое снимает половину «плавающих» тестов: в коде под тестом не используйте наивные datetime. Беритеdatetime.now(datetime.UTC)и явную зону черезzoneinfo.ZoneInfo. Тогда результат не зависит от настройки машины.
import datetime
from zoneinfo import ZoneInfo
def local_hour(tz_name: str) -> int:
# осознанно работаем в конкретной зоне, а не в зоне сервера
now = datetime.datetime.now(ZoneInfo(tz_name))
return now.hourСлучайность: seed недостаточно
Классический совет «зафиксируйте seed» работает, но у него две дыры. Первая: random.seed(0) в начале теста не спасёт, если другой тест успел дёрнуть глобальный генератор раньше и порядок выполнения поменялся. Вторая: seed для random не влияет на numpy.random, на secrets и на uuid.uuid4() — у каждого свой источник.
Надёжнее не полагаться на глобальное состояние, а внедрять генератор как зависимость. Тогда тест передаёт свой экземпляр и не трогает общий:
import random
def pick_winner(participants, rng=None):
# rng внедряется извне; по умолчанию — глобальный генератор
rng = rng or random
return rng.choice(participants)
def test_pick_winner_is_deterministic():
local_rng = random.Random(42) # изолированный генератор
winner = pick_winner(["a", "b", "c", "d"], rng=local_rng)
assert winner == "d" # зафиксируйте реальным прогономЗначение "d" в примере условное: получите его один раз реальным запуском и впишите. Не подбирайте на глаз — конкретная последовательность зависит от версии CPython, хоть и меняется редко.
uuid и текущее время внутри случайности
uuid.uuid4() использует системный источник энтропии, seed на него не действует. Если код генерирует идентификаторы, вынесите генерацию за фикстуру-фабрику или замокайте сам uuid.uuid4 в модуле, где он вызывается:
from unittest.mock import patch
import uuid
# предположим, код под тестом лежит в модуле app.orders
# и делает: from uuid import uuid4
def test_order_id_is_fixed():
fixed = uuid.UUID("12345678-1234-5678-1234-567812345678")
with patch("app.orders.uuid4", return_value=fixed):
# ... вызов кода, который создаёт заказ
passСеть: живой запрос в тесте — это не тест
Тест, который реально ходит на внешний API, падает при недоступности сети, меняет своё поведение вместе с чужим сервером и тормозит прогон. Заодно он способен создать реальную запись в продовой системе, если кто-то забыл про тестовый контур. Сеть в юнит-тестах надо перехватывать.
Инструмент зависит от HTTP-клиента. Ниже — для requests через библиотеку responses:
import requests
import responses
@responses.activate
def test_fetch_user():
responses.add(
responses.GET,
"https://api.example.com/users/1",
json={"id": 1, "name": "Ivan"},
status=200,
)
resp = requests.get("https://api.example.com/users/1")
assert resp.json()["name"] == "Ivan"
# проверяем, что запрос вообще был сделан
assert len(responses.calls) == 1Для асинхронного httpx используйте respx — у него другой API, заточенный под httpx-транспорт. Не пытайтесь мокать сокеты руками: вы воспроизведёте половину HTTP-стека и всё равно упустите заголовки или редиректы.
Заблокировать сеть глобально
Чтобы ни один тест не ушёл в сеть незаметно, поставьте pytest-socket и включите блокировку в конфигурации. Тогда любой незамоканный запрос упадёт сразу с понятной ошибкой, а не будет молча висеть на таймауте.
Сравнение источников недетерминизма
| Источник | Чем изолировать | Где ломается |
|---|---|---|
| Текущее время | freezegun / time-machine | Время в СУБД и C-расширениях не замораживается |
| Часовой пояс | zoneinfo + aware datetime в коде | Наивные datetime в старом коде остаются зависимыми от машины |
| random | Внедрение random.Random или seed | numpy, secrets, uuid имеют отдельные источники |
| uuid4 | patch символа в модуле вызова | Патч не там, где импортирован объект |
| HTTP | responses / respx + pytest-socket | Каждый клиент требует своего мокера |
Что делать дальше
Начните с диагностики: запустите набор с плагином pytest-randomly, который перемешивает порядок тестов и фиксирует seed случайности. Флейки, зависящие от порядка выполнения или от глобального генератора, всплывут сразу. Затем включите блокировку сети через pytest-socket — так вы увидите все тесты, которые молча ходили наружу. Замораживайте время только там, где код действительно от него зависит, а не глобальной фикстурой на весь проект: лишняя заморозка замедляет прогон и маскирует реальные баги с таймзонами.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: freezegun — GitHub, responses — GitHub
Частые вопросы
У меня Python 3.9, а в примерах datetime.UTC и zoneinfo. Что делать?
freezegun не замораживает время — тест всё равно берёт реальное. Почему?
patch на uuid4 не срабатывает, объект остаётся настоящим. В чём ошибка?
Стоит ли вообще мокать сеть, если есть тестовый контур API?
Я зафиксировал seed, но значение теста меняется между версиями Python. Это нормально?
pytest-randomly ломает мои тесты — они падают при перемешивании порядка. Это баг плагина?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.