Как стать автором
Обновить
593.6
OTUS
Цифровые навыки от ведущих экспертов

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

Время на прочтение8 мин
Количество просмотров1.2K

Привет, Хабр!

Время — это одна из самых нестабильных переменных в коде (и не только). Оно безжалостно к 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, которые помогут разобраться в ключевых аспектах:

Теги:
Хабы:
Всего голосов 16: ↑8 и ↓8+4
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS