Контроль времени в Python-тестах: freeze, mock и архитектура Clock

Привет, Хабр!
Время — это одна из самых нестабильных переменных в коде (и не только). Оно безжалостно к CI, случайным багам и здравому смыслу. Особенно если вы пишете логику, где участвует datetime.now (), time.time () или utcnow (): TTL, крон-задачи, дедлайны, отложенные события, idempotency-окна, подписки, отложенная отправка писем, повторная авторизация — всё это работает с временными сдвигами. И всё это будет ломаться, если не заморозить время в тестах.
В этой статье рассмотрим, как выстроить адекватную архитектуру контроля времени: от простых фиксаций до внедрения Clock-абстракции.
Почему тесты, зависящие от времени, ненадёжны
Вы пишете такое:
def is_expired(created_at: datetime, ttl_seconds: int) -> bool:
return datetime.utcnow() > created_at + timedelta(seconds=ttl_seconds)И вот тест:
def test_expired():
created_at = datetime.utcnow()
assert not is_expired(created_at, 60)Между строками — микросекунды. И в зависимости от нагрузки на машину и CI он может фликать. Сегодня прошёл, завтра — упал. Такие тесты никто не любит. И, главное, никто им не верит. Это прямой путь к игнору CI-пайплайна.
Решение №1: фиксируем время через freezegun
Если вы работаете с временем в тестах — freezegun это мастхев. Библиотека позволяет зафризить время внутри теста на нужной вам дате и времени. Она перехватывает вызовы datetime.now(), datetime.utcnow(), date.today() и time.time() через подмену стандартных функций.
Установка:
pip install time-machine=Простое использование
Базовый пример:
import time_machine
from datetime import datetime
def test_travel():
with time_machine.travel("2025-04-01 10:00:00"):
assert datetime.now() == datetime(2025, 4, 1, 10, 0, 0)Декоратор @freeze_time устанавливает виртуальное текущее время. Все вызовы datetime.utcnow(), datetime.now() и даже time.time() в теле теста будут возвращать одну и ту же точку — 2025–04–01 12:00:00. Тест становится полностью детерминированным, даже если в логике присутствует несколько вызовов времени.
Контекстный менеджер
Если не хочется оборачивать всю функцию, можно использовать freeze_time как with.
async def fetch_data_with_timeout(created_at: datetime, ttl: int) -> bool:
await asyncio.sleep(0.1) # имитируем сетевой вызов
return datetime.utcnow() > created_at + timedelta(seconds=ttl)Применимо, если хочется заморозить время только внутри определённого участка кода, а остальное — оставить работать.
Передвижение времени внутри теста
Одна из фич freezegun — можно двигать замороженное время вручную. Например, чтобы проверить, как логика поведёт себя через 30 минут после события.
from unittest.mock import patch
from datetime import datetime
def test_with_patch():
with patch("yourmodule.datetime") as mock_datetime:
mock_datetime.utcnow.return_value = datetime(2025, 4, 1, 12, 0, 0)
assert yourmodule.datetime.utcnow() == datetime(2025, 4, 1, 12, 0, 0)move_to() работает только в рамках текущего контекста with. Выйдете за его пределы — и все фиксы сбросятся.
Как freezegun работает
Под капотом freezegun делает monkey-patch следующих точек:
datetime.datetime.nowdatetime.datetime.utcnowdatetime.date.todaytime.time
Когда вы вызываете, например, datetime.utcnow(), библиотека подменяет поведение и возвращает замороженное значение.
Однако: если вы импортировали now() напрямую в модуль — например, так:
import time_machine
import time
def test_tick():
with time_machine.travel("2025-04-01 10:00:00", tick=True):
t1 = datetime.now()
time.sleep(1)
t2 = datetime.now()
assert (t2 - t1).total_seconds() >= 1И потом сделали:
datetime.now()То всё работает. Но если вы сделали:
from datetime import datetime
now = datetime.now # сохранили ссылкуИ вызвали now() позже — поведение может быть нестабильным, потому что freezegun не патчит уже сохранённые ссылки. Т.е:
from datetime import datetime
NOW = datetime.utcnow()
@freeze_time("2025-04-01")
def test_fail():
assert NOW == datetime(2025, 4, 1) # это упадётТакие конструкции нужно избегать.
Проверка работы с time.time ()
Если вы используете библиотеки, завязанные на Unix-время (например, Redis TTL, логгеры, токены с exp в секундах) — полезно убедиться, что freezegun патчит time.time().
from datetime import datetime
class Clock:
def now(self) -> datetime:
return datetime.utcnow()Если вы этого не сделаете, то можете получить нестыковки: datetime.now() даст одно время, а time.time() — другое.
Работа с параметром tick
По умолчанию freezegun замораживает время. Но вы можете заставить его течь, как будто оно живое:
class TokenManager:
def __init__(self, clock: Clock):
self.clock = clock
def is_expired(self, created_at: datetime, ttl: int) -> bool:
return self.clock.now() > created_at + timedelta(seconds=ttl)В этом случае datetime.now() будет возвращать движущееся время, синхронное с системным.
Локальное время и часовые пояса
По дефолту freezegun работает с локальным временем (зависит от системы). Если вы работаете с UTC — используйте datetime.utcnow().
class FakeClock(Clock):
def __init__(self, fixed: datetime):
self._now = fixed
def now(self) -> datetime:
return self._now
def travel(self, to: datetime):
self._now = tofreezegun не патчит сторонние библиотеки вроде arrow, pendulum, dateutil. Если вы используете их — либо отключайте их в тестах, либо вручную мокайте datetime.
Ограничения freezegun
Безусловно freezegun имеет ряд ограничений: он не работает с async def-функциями (декоратор @freeze_time(...) не срабатывает на асинхронные тесты), не патчит сторонние библиотеки вроде pendulum, arrow и других обёрток над временем, а также не гарантирует стабильность в многопоточной среде — при параллельных тестах или потоках поведение может быть нестабильным.
Решение №2: более гибкая time-machine
Если freezegun — это замораживатель времени, то time-machine — это полноценная машина времени с поддержкой асинхронности, точного контроля и симуляции живого времени. Библиотека моложе, но решает те задачи, которые freezegun даже не пытается.
Устанавливается стандартно:
pip install time-machinetime-machine патчит сразу три источника времени
import datetime
import timedatetime.datetime.now()datetime.datetime.utcnow()time.time()
Важно, если вы используете сторонние библиотеки, которые берут текущий timestamp в секундах — например, Redis TTL, JWT, брокеры сообщений, или просто time.time () для дедлайнов в seconds.
Статичная фиксация времени
Стартовая точка — обычное перемещение во времени через контекст:
import time_machine
from datetime import datetime
def test_travel():
with time_machine.travel("2025-04-01 10:00:00"):
assert datetime.now() == datetime(2025, 4, 1, 10, 0, 0)Здесь datetime.now() и datetime.utcnow() возвращают фиксированную дату. Аналогично и time.time() — он тоже отдаёт timestamp, соответствующий этой дате.
Но в отличие от freezegun, здесь всё это работает и в асинхронном коде. Без декораторов, без ограничений.
Поддержка async-контекста
Пример с асинхронной функцией:
import time_machine
import asyncio
from datetime import datetime
async def expensive_call():
await asyncio.sleep(0.01)
return datetime.now()
@time_machine.travel("2025-04-01 10:00:00")
async def test_async_code():
result = await expensive_call()
assert result == datetime(2025, 4, 1, 10, 0, 0)Вам не нужно адаптировать библиотеку — @travel(...) сам работает как декоратор над async def.
Т.е если вы пишете сервисы на FastAPI, Sanic, AIOHTTP, или у вас просто async background workers — вам сюда. freezegun в таких сценариях не работает вовсе.
Управление временем в живом режиме (tick=True)
По дефолту time-machine работает как фризер: зафиксировал дату — и живёт в ней. Но если вы хотите, чтобы время текло, можно включить «tick»:
from datetime import datetime
import time_machine
import time
def test_with_tick():
with time_machine.travel("2025-04-01 08:00:00", tick=True):
start = datetime.now()
time.sleep(1.1) # реальные 1.1 секунды
end = datetime.now()
assert (end - start).total_seconds() >= 1С помощью этого можно тестить:
логику idle-timeout (например, веб-сокеты),
реакцию на delay между событиями,
планировщики задач, где время должно проходить естественно.
Контроль времени через travel + shift
Одна из главных фич time-machine — вы можете динамически смещать время, не выходя из текущего контекста:
from datetime import datetime, timedelta
import time_machine
def test_shift_time():
with time_machine.travel("2025-04-01 10:00:00") as travel:
now = datetime.now()
assert now == datetime(2025, 4, 1, 10, 0, 0)
# Смещаемся на 2 часа вперёд
travel.shift(timedelta(hours=2))
assert datetime.now() == datetime(2025, 4, 1, 12, 0, 0)Можно симулировать прохождение времени прямо в одном тесте без перезапуска контекста.
Управление временем в тестовых фикстурах и через декораторы
time-machine легко интегрируется в тестовую инфраструктуру.
Пример через pytest-фикстуру:
import pytest
import time_machine
from datetime import datetime
@pytest.fixture
def fixed_time():
with time_machine.travel("2025-04-01 14:00:00"):
yield
def test_under_fixed_time(fixed_time):
assert datetime.utcnow() == datetime(2025, 4, 1, 14, 0, 0)Можно объявить эту фикстуру глобально и использовать в любом тесте.
Валидация timestamp через time.time ()
Если вы храните или сравниваете timestamp в секундах (например, exp, iat, TTL), time.time() должен быть под контролем.
import time
import time_machine
def test_unix_timestamp():
with time_machine.travel("2025-04-01 00:00:00"):
timestamp = time.time()
assert int(timestamp) == 1733164800 # это UNIX-время 2025-04-01 00:00:00 UTCВ freezegun это работало нестабильно, в time-machine — всегда надёжно. Потому что он патчит низкоуровневую функцию time.time() напрямую.
Ограничения
time-machine не патчит datetime.date.today() (в отличие от freezegun). Если вы работаете с датами без времени — патчить придётся руками.
Библиотека чувствительна к локали и tzinfo: если используете datetime.now(tz=...), она будет возвращать результат корректно, но смещение может требовать явного указания timezone.utc в логике.
Не работает в окружениях, где time и datetime импорты закэшированы нестандартным образом (например, в Cython-приложениях или в пропатченных окружениях).
Clock-интерфейс как зависимость
Замораживать глобальное время — удобно, но не всегда безопасно. Особенно в больших кодовых базах. Поэтому во многих системах создают интерфейс обёртку над временем, называемый Clock, и внедряют его через DI или сервис-локатор.
from unittest.mock import patch
from datetime import datetime
def test_with_patch():
with patch("yourmodule.datetime") as mock_datetime:
mock_datetime.utcnow.return_value = datetime(2025, 4, 1, 12, 0, 0)
assert yourmodule.datetime.utcnow() == datetime(2025, 4, 1, 12, 0, 0)Вы используете clock.now() везде, где раньше был datetime.utcnow():
class TokenManager:
def __init__(self, clock: Clock):
self.clock = clock
def is_expired(self, created_at: datetime, ttl: int) -> bool:
return self.clock.now() > created_at + timedelta(seconds=ttl)
В продакшене — настоящий Clock. В тестах — FakeClock:
class FakeClock(Clock):
def __init__(self, fixed: datetime):
self._now = fixed
def now(self) -> datetime:
return self._now
def travel(self, to: datetime):
self._now = toПример теста:
def test_expired_with_fake_clock():
clock = FakeClock(datetime(2025, 4, 1, 12, 0, 0))
manager = TokenManager(clock)
created_at = datetime(2025, 4, 1, 11, 0, 0)
assert manager.is_expired(created_at, 1800) is TrueЭто архитектурно чисто: вы инвертируете зависимость от времени, и весь ваш код становится детерминированным.
Выводы
Контроль времени — обязательная часть зрелого тестирования. Если вы хотите стабильных пайплайнов и уверенности в своей логике, научитесь управлять временем. Сначала через freezegun или time-machine. Потом — через архитектурные абстракции Clock.
А как у вас? Как вы подходите к контролю времени в своих проектах? Используете freezegun, time-machine, может быть, внедряете Clock через DI? Или до сих пор боретесь с падением тестов на datetime.now()? Делитесь в комментарях.
Если вам важно улучшить подход к тестированию и работе с документацией, рекомендую обратить внимание на два открытых урока в Otus, которые помогут разобраться в ключевых аспектах:
10 апреля. Как найти баг и задокументировать его?
22 апреля. Виды тестовой документации и их практическое использование.
Habrahabr.ru прочитано 8047 раз
