Привет, Хабр!
Время — это одна из самых нестабильных переменных в коде (и не только). Оно безжалостно к 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.now
-
datetime.datetime.utcnow
-
datetime.date.today
-
time.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 = to
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_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, которые помогут разобраться в ключевых аспектах:
ссылка на оригинал статьи https://habr.com/ru/articles/898546/
Добавить комментарий