Мокирование времени и файлов
У вас есть функция 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()