Контроль времени в 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.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/