Перейти к содержимому
К программе курса
Pytest для джунов: Моки и интеграция
4 / 850%

Мокирование времени и файлов

25 минут

У вас есть функция calculate_age(birthdate) которая использует datetime.now(). Тест работает сегодня, но сломается через год. Как сделать тест стабильным?

Цель: Научиться мокировать время и файловый ввод-вывод.

Вы точно готовы?

Убедитесь, что умеете:

# Мокировать HTTP с requests-mock
import requests_mock
 
def test_api(mock_requests):
    mock_requests.get("https://api.example.com/data", json={"value": 123})

Если requests-mock непонятен — вернитесь к Мокирование HTTP.

Проблема 1: непредсказуемое время

Код зависящий от времени

# src/user.py
from datetime import datetime
 
class User:
    def __init__(self, name, birthdate):
        self.name = name
        self.birthdate = birthdate  # "2000-01-15"
 
    def get_age(self):
        """Возвращает возраст пользователя"""
 
        today = datetime.now().date()
        birth = datetime.fromisoformat(self.birthdate).date()
        age = today.year - birth.year
        # Корректируем если день рождения ещё не наступил
        if (today.month, today.day) < (birth.month, birth.day):
            age -= 1
        return age
 
    def has_birthday_today(self):
        """Проверяет день рождения сегодня"""
 
        today = datetime.now().date()
        birth = datetime.fromisoformat(self.birthdate).date()
        return (today.month, today.day) == (birth.month, birth.day)

Тест который сломается

# ❌ ПЛОХО — тест зависит от даты запуска
def test_user_age():
    user = User("Alice", "2000-01-15")
 
    # Если сегодня 2025-01-16 — возраст 25
    # Если сегодня 2025-01-14 — возраст 24
    # Тест нестабильный!
    age = user.get_age()
    assert age == 25  # Сломается через год или до дня рождения!
 
def test_birthday_today():
    user = User("Alice", "2000-01-15")
 
    # ❌ Работает только 15 января!
    assert user.has_birthday_today() == True

Проблемы:

  • ❌ Тесты работают по-разному в зависимости от даты запуска
  • ❌ Невозможно протестировать конкретную дату
  • ❌ Сложно тестировать edge cases (високосные годы, разные часовые пояса)

Решение 1: freezegun

Установка

pip install freezegun

Базовое использование

from freezegun import freeze_time
from datetime import datetime
 
@freeze_time("2025-01-15")
def test_frozen_time():
    """Время 'заморожено' на 15 января 2025"""
 
    now = datetime.now()
    print(now)  # 2025-01-15 00:00:00
 
    # Время не меняется!
    import time
    time.sleep(1)
    print(datetime.now())  # Всё ещё 2025-01-15 00:00:00

✅ Тест всегда работает с одной и той же датой!

Тестирование возраста

from freezegun import freeze_time
 
@freeze_time("2025-01-16")  # Сегодня 16 января 2025
def test_user_age():
    """Возраст пользователя родившегося 15 января 2000"""
 
    user = User("Alice", "2000-01-15")
 
    age = user.get_age()
 
    assert age == 25  # 16 января 2025 — уже 25 лет
 
@freeze_time("2025-01-14")  # Сегодня 14 января 2025 (ДО дня рождения)
def test_user_age_before_birthday():
    """Возраст до дня рождения"""
 
    user = User("Alice", "2000-01-15")
 
    age = user.get_age()
 
    assert age == 24  # День рождения ещё не наступил — 24 года

✅ Тесты стабильны и проверяют разные сценарии!

Тестирование дня рождения

@freeze_time("2025-01-15")
def test_birthday_today():
    """Проверка дня рождения"""
 
    user = User("Alice", "2000-01-15")
 
    assert user.has_birthday_today() == True
 
@freeze_time("2025-01-14")
def test_not_birthday():
    """Не день рождения"""
 
    user = User("Alice", "2000-01-15")
 
    assert user.has_birthday_today() == False

Контекстный менеджер

from freezegun import freeze_time
 
def test_time_travel():
    """Можно 'путешествовать' во времени внутри теста"""
    with freeze_time("2025-01-01"):
        assert datetime.now().day == 1
 
    with freeze_time("2025-12-31"):
        assert datetime.now().day == 31
 
    # Вне блока — реальное время
    print(datetime.now())  # Реальная дата

Время с часами и минутами

@freeze_time("2025-01-15 10:30:00")
def test_specific_time():
    """Заморозка конкретного времени"""
 
    now = datetime.now()
 
    assert now.hour == 10
    assert now.minute == 30
    assert now.second == 0

Практический пример: SessionManager

# src/session_manager.py
from datetime import datetime, timedelta
 
class SessionManager:
    def __init__(self, timeout_minutes=30):
        self.timeout_minutes = timeout_minutes
        self.sessions = {}
 
    def create_session(self, user_id):
        """Создаёт сессию"""
 
        session_id = f"session_{user_id}"
        self.sessions[session_id] = {
            "user_id": user_id,
            "created_at": datetime.now(),
            "expires_at": datetime.now() + timedelta(minutes=self.timeout_minutes)
        }
        return session_id
 
    def is_valid(self, session_id):
        """Проверяет валидность сессии"""
 
        if session_id not in self.sessions:
            return False
 
        session = self.sessions[session_id]
        return datetime.now() < session["expires_at"]
 
# tests/test_session_manager.py
from freezegun import freeze_time
 
@freeze_time("2025-01-15 10:00:00")
def test_create_session():
    """Создание сессии"""
 
    manager = SessionManager(timeout_minutes=30)
 
    session_id = manager.create_session(user_id=1)
 
    session = manager.sessions[session_id]
    assert session["created_at"] == datetime(2025, 1, 15, 10, 0, 0)
    assert session["expires_at"] == datetime(2025, 1, 15, 10, 30, 0)
 
@freeze_time("2025-01-15 10:00:00")
def test_session_valid():
    """Сессия валидна сразу после создания"""
 
    manager = SessionManager(timeout_minutes=30)
    session_id = manager.create_session(user_id=1)
 
    assert manager.is_valid(session_id) == True
 
@freeze_time("2025-01-15 10:00:00")
def test_session_expires():
    """Сессия истекает через timeout"""
 
    manager = SessionManager(timeout_minutes=30)
    session_id = manager.create_session(user_id=1)
 
    # Перемещаемся на 31 минуту вперёд
    with freeze_time("2025-01-15 10:31:00"):
        assert manager.is_valid(session_id) == False
 
@freeze_time("2025-01-15 10:00:00")
def test_session_still_valid():
    """Сессия валидна за 1 минуту до истечения"""
 
    manager = SessionManager(timeout_minutes=30)
    session_id = manager.create_session(user_id=1)
 
    # 29 минут спустя — ещё валидна
    with freeze_time("2025-01-15 10:29:00"):
        assert manager.is_valid(session_id) == True

✅ Можем тестировать таймауты БЕЗ ожидания!

Проблема 2: побочные эффекты файлов

Код работающий с файлами

# src/config_reader.py
 
def read_config(filename):
    """Читает конфигурацию из файла"""
 
    with open(filename, "r") as f:
        content = f.read()
    return content
 
def save_report(filename, data):
    """Сохраняет отчёт в файл"""
 
    with open(filename, "w") as f:
        f.write(data)
    return True

Тест с реальными файлами (плохо)

import os
 
def test_read_config():
    # ❌ Создаём реальный файл
 
    with open("test_config.txt", "w") as f:
        f.write("config_value=123")
 
    content = read_config("test_config.txt")
 
    assert "config_value" in content
 
    # Cleanup
    os.remove("test_config.txt")

Проблемы:

  • ❌ Создаются реальные файлы на диске
  • ❌ Нужен cleanup
  • ❌ Могут быть конфликты (файл уже существует)
  • ❌ Медленнее (I/O операции)

Решение 2: mock_open()

Базовое использование

from unittest.mock import patch, mock_open
 
@patch("builtins.open", mock_open(read_data="config_value=123"))
def test_read_config():
    """Тест чтения файла БЕЗ реального файла"""
 
    content = read_config("any_filename.txt")
 
    assert "config_value" in content

✅ Файл НЕ создан, всё работает в памяти!

Проверка записи файла

@patch("builtins.open", mock_open())
def test_save_report(mock_file):
    """Тест записи файла"""
 
    # Act
    result = save_report("report.txt", "Test data")
 
    # Assert
    assert result == True
 
    # Проверяем что open был вызван правильно
    mock_file.assert_called_once_with("report.txt", "w")
 
    # Проверяем что write был вызван с правильными данными
    handle = mock_file()
    handle.write.assert_called_once_with("Test data")

Мокирование чтения и записи

# src/file_processor.py
 
def process_file(input_file, output_file):
    """Читает файл, обрабатывает и записывает результат"""
 
    with open(input_file, "r") as f:
        content = f.read()
 
    processed = content.upper()  # Простая обработка
 
    with open(output_file, "w") as f:
        f.write(processed)
 
    return True
 
# tests/test_file_processor.py
from unittest.mock import patch, mock_open, call
 
@patch("builtins.open")
def test_process_file(mock_file):
    """Тест обработки файла"""
 
    # Мокируем чтение (input)
    mock_file.return_value.__enter__.return_value.read.return_value = "hello world"
 
    # Act
    result = process_file("input.txt", "output.txt")
 
    # Assert
    assert result == True
 
    # Проверяем что open вызван дважды (чтение + запись)
    assert mock_file.call_count == 2
 
    # Проверяем первый вызов (чтение)
    assert mock_file.call_args_list[0] == call("input.txt", "r")
 
    # Проверяем второй вызов (запись)
    assert mock_file.call_args_list[1] == call("output.txt", "w")
 
    # Проверяем что записано
    handle = mock_file()
    handle.write.assert_called_once_with("HELLO WORLD")

Практический пример: LogManager

# src/log_manager.py
from datetime import datetime
 
class LogManager:
    def __init__(self, log_file):
        self.log_file = log_file
 
    def log(self, level, message):
        """Записывает лог в файл"""
 
        timestamp = datetime.now().isoformat()
        log_entry = f"[{timestamp}] {level}: {message}\n"
 
        with open(self.log_file, "a") as f:
            f.write(log_entry)
 
    def get_logs(self):
        """Читает все логи"""
 
        try:
            with open(self.log_file, "r") as f:
                return f.read()
        except FileNotFoundError:
            return ""
 
# tests/test_log_manager.py
from unittest.mock import patch, mock_open
from freezegun import freeze_time
 
@freeze_time("2025-01-15 10:30:00")
@patch("builtins.open", mock_open())
def test_log_entry(mock_file):
    """Тест записи лога"""
 
    logger = LogManager("app.log")
 
    logger.log("INFO", "Application started")
 
    # Проверяем что файл открыт в режиме append
    mock_file.assert_called_once_with("app.log", "a")
 
    # Проверяем содержимое
    handle = mock_file()
    handle.write.assert_called_once_with(
        "[2025-01-15T10:30:00] INFO: Application started\n"
    )
 
@patch("builtins.open", mock_open(read_data="[2025-01-15T10:00:00] INFO: Test log\n"))
def test_get_logs(mock_file):
    """Тест чтения логов"""
 
    logger = LogManager("app.log")
 
    logs = logger.get_logs()
 
    assert "Test log" in logs
    mock_file.assert_called_once_with("app.log", "r")
 
@patch("builtins.open", side_effect=FileNotFoundError)
def test_get_logs_file_not_exists(mock_file):
    """Тест когда файла нет"""
 
    logger = LogManager("app.log")
 
    logs = logger.get_logs()
 
    assert logs == ""

Комбинирование: время + файлы

# src/backup_manager.py
from datetime import datetime
import json
 
class BackupManager:
    def create_backup(self, data):
        """Создаёт резервную копию с timestamp"""
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"backup_{timestamp}.json"
 
        with open(filename, "w") as f:
            json.dump(data, f)
 
        return filename
 
# tests/test_backup_manager.py
from freezegun import freeze_time
from unittest.mock import patch, mock_open
 
@freeze_time("2025-01-15 10:30:00")
@patch("builtins.open", mock_open())
def test_create_backup(mock_file):
    """Тест создания бэкапа"""
    manager = BackupManager()
    data = {"users": [1, 2, 3]}
 
    filename = manager.create_backup(data)
 
    # Проверяем имя файла
    assert filename == "backup_20250115_103000.json"
 
    # Проверяем что файл создан
    mock_file.assert_called_once_with("backup_20250115_103000.json", "w")
 
    # Проверяем что данные записаны
    handle = mock_file()
    written_data = handle.write.call_args[0][0]
    assert "users" in written_data

✅ Тестируем время И файлы БЕЗ побочных эффектов!

Что вы изучили

  • freezegun — мокирование datetime.now()
  • @freeze_time() — фиксация времени в тестах
  • Тестирование дат — возраст, дни рождения, таймауты
  • mock_open() — мокирование file I/O
  • Проверка записи — что записано в файл
  • Проверка чтения — что прочитано из файла
  • Комбинирование — время + файлы в одном тесте

Следующий урок

Отлично! Теперь вы умеете мокировать HTTP, время и файлы. Но как понять какой процент кода покрыт тестами?

Переходите к уроку 4: Coverage: контроль качества тестов

В следующем уроке вы узнаете:

  • pytest-cov установка и настройка
  • pytest --cov для измерения покрытия
  • Чтение метрик: lines, branches, functions
  • HTML отчёты для визуализации
  • Интеграция с IDE

Устранение неисправностей

Импортируйте datetime правильно:

# ✅ ПРАВИЛЬНО
from datetime import datetime
datetime.now()  # Будет замокирован

# ❌ НЕПРАВИЛЬНО

import datetime
datetime.datetime.now()  # Может не работать

Используйте mock_open() без аргументов и настраивайте return_value:

m = mock_open(read_data="content")
with patch("builtins.open", m):
  # Работает

Используйте side_effect с функцией:

def open_side_effect(filename, mode):
  if filename == "file1.txt":
      return mock_open(read_data="content1")()
  elif filename == "file2.txt":
      return mock_open(read_data="content2")()

with patch("builtins.open", side_effect=open_side_effect): # Разные файлы возвращают разный контент

Проверьте что вы патчите правильный модуль:

# src/module.py использует:
from datetime import datetime # Импорт в модуле

# Патчите ТАМ где используется:

@freeze_time("2025-01-15") # ✅ Работает автоматически для datetime.now()
Мокирование времени и файлов — Pytest для джунов: Моки и интеграция — Potapov.me