Привет, Хабр!
Время — это одна из самых нестабильных переменных в коде (и не только). Оно безжалостно к 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=
Простое использование
Базовый пример:
from datetime import datetime, timedelta
from freezegun import freeze_time
@freeze_time("2025-04-01 12:00:00")
def test_token_expiration():
created_at = datetime.utcnow()
assert created_at == datetime(2025, 4, 1, 12, 0, 0)
expired = datetime.utcnow() > created_at + timedelta(seconds=3600)
assert not expired
Декоратор @freeze_time
устанавливает виртуальное текущее время. Все вызовы datetime.utcnow()
, datetime.now()
и даже time.time()
в теле теста будут возвращать одну и ту же точку — 2025-04-01 12:00:00. Тест становится полностью детерминированным, даже если в логике присутствует несколько вызовов времени.
Контекстный менеджер
Если не хочется оборачивать всю функцию, можно использовать freeze_time
как with
.
from datetime import datetime
from freezegun import freeze_time
def test_context_freeze():
with freeze_time("2025-04-01 10:00:00"):
assert datetime.now() == datetime(2025, 4, 1, 10, 0, 0)
Применимо, если хочется заморозить время только внутри определённого участка кода, а остальное — оставить работать.
Передвижение времени внутри теста
Одна из фич freezegun — можно двигать замороженное время вручную. Например, чтобы проверить, как логика поведёт себя через 30 минут после события.
from datetime import datetime, timedelta
from freezegun import freeze_time
def test_time_travel():
with freeze_time("2025-04-01 10:00:00") as frozen:
start = datetime.now()
assert start == datetime(2025, 4, 1, 10, 0, 0)
# Сдвигаем время на 30 минут вперёд
frozen.move_to("2025-04-01 10:30:00")
assert datetime.now() == datetime(2025, 4, 1, 10, 30, 0)
# Проверяем дельту
delta = datetime.now() - start
assert delta == timedelta(minutes=30)
move_to()
работает только в рамках текущего контекста with
. Выйдете за его пределы — и все фиксы сбросятся.
Как freezegun работает
Под капотом freezegun делает monkey-patch следующих точек:
datetime.datetime.now
datetime.datetime.utcnow
datetime.date.today
time.time
Когда вы вызываете, например, datetime.utcnow()
, библиотека подменяет поведение и возвращает замороженное значение.
Однако: если вы импортировали now()
напрямую в модуль — например, так:
from datetime import datetime
И потом сделали:
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()
.
import time
from datetime import datetime
from freezegun import freeze_time
def test_time_time():
with freeze_time("2025-04-01 12:00:00"):
dt = datetime(2025, 4, 1, 12, 0, 0)
assert int(time.time()) == int(dt.timestamp())
Если вы этого не сделаете, то можете получить нестыковки: datetime.now()
даст одно время, а time.time()
— другое.
Работа с параметром tick
По умолчанию freezegun замораживает время. Но вы можете заставить его течь, как будто оно живое:
@freeze_time("2025-04-01 10:00:00", tick=True)
def test_time_flows():
import time
t1 = datetime.now()
time.sleep(1.2)
t2 = datetime.now()
assert (t2 - t1).total_seconds() >= 1
В этом случае datetime.now()
будет возвращать движущееся время, синхронное с системным.
Локальное время и часовые пояса
По дефолту freezegun работает с локальным временем (зависит от системы). Если вы работаете с UTC — используйте datetime.utcnow()
.
@freeze_time("2025-04-01 10:00:00")
def test_utc_consistency():
assert datetime.utcnow() == datetime(2025, 4, 1, 10, 0, 0)
freezegun не патчит сторонние библиотеки вроде arrow
, pendulum
, dateutil
. Если вы используете их — либо отключайте их в тестах, либо вручную мокайте datetime
.
Ограничения freezegun
Безусловно freezegun имеет ряд ограничений: он не работает с async def-функциями (декоратор @freeze_time(...)
не срабатывает на асинхронные тесты), не патчит сторонние библиотеки вроде pendulum
, arrow
и других обёрток над временем, а также не гарантирует стабильность в многопоточной среде — при параллельных тестах или потоках поведение может быть нестабильным.
Решение №2: более гибкая time-machine
Если freezegun — это замораживатель времени, то time-machine — это полноценная машина времени с поддержкой асинхронности, точного контроля и симуляции живого времени. Библиотека моложе, но решает те задачи, которые freezegun даже не пытается.
Устанавливается стандартно:
pip install time-machine
time-machine патчит сразу три источника времени
import datetime
import time
datetime.datetime.now()
datetime.datetime.utcnow()
time.time()
Важно, если вы используете сторонние библиотеки, которые берут текущий timestamp в секундах — например, Redis TTL, JWT, брокеры сообщений, или просто time.time() для дедлайнов в seconds.
Статичная фиксация времени
Стартовая точка — обычное перемещение во времени через контекст:
import time_machine
from datetime import datetime
def test_static_travel():
with time_machine.travel("2025-04-01 10:00:00"):
now = datetime.now()
assert 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
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
Можно симулировать прохождение времени прямо в одном тесте без перезапуска контекста.
Управление временем в тестовых фикстурах и через декораторы
time-machine легко интегрируется в тестовую инфраструктуру.
Пример через pytest-фикстуру:
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
Можно объявить эту фикстуру глобально и использовать в любом тесте.
Валидация 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 datetime import datetime
class Clock:
def now(self) -> datetime:
return datetime.utcnow()
Вы используете 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, которые помогут разобраться в ключевых аспектах: