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

Мини-проект: Todo с кешем и API

30 минут

Вы изучили моки, фикстуры, coverage, pytest.ini, conftest.py. Пора применить всё вместе на реальном проекте!

Цель: Создать Todo-приложение с тестами используя все навыки курса.

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

Убедитесь, что прошли уроки 0-6:

  • Мокирование (HTTP, время, файлы)
  • unittest.mock, requests-mock, freezegun
  • Coverage (pytest-cov)
  • pytest.ini конфигурация
  • conftest.py для фикстур

Если что-то непонятно — вернитесь к предыдущим урокам.

Обзор проекта

Что будем строить

Todo-приложение с:

  • CRUD операции (Create, Read, Update, Delete)
  • Redis кеш для ускорения
  • Фиктивный HTTP API для получения вдохновляющих цитат
  • Обработка ошибок (сеть, БД, кеш)

Как работает приложение

Основной сценарий:

  1. Пользователь создаёт задачуTodoService.create_todo("Buy milk")

    • TodoService сохраняет в Repository (in-memory БД)
    • Опционально получает мотивационную цитату из QuotesAPI
    • Инвалидирует кеш списка задач
  2. Пользователь получает задачуTodoService.get_todo(1)

    • Сначала проверяет RedisCache
    • Если в кеше нет → берёт из Repository
    • Сохраняет в кеш для следующих запросов
  3. Пользователь помечает выполненнойTodoService.complete_todo(1)

    • Обновляет в Repository
    • Инвалидирует кеш (чтобы обновились данные)

Зачем кеш? В реальном приложении БД медленная. Кеш ускоряет частые запросы (get_todo, get_all).

Зачем HTTP API? Показывает как тестировать внешние зависимости с помощью моков (requests-mock).

Сервис цитат нарочно фейковый: https://quotes.invalid/random не существует. Он нужен, чтобы тренироваться мокать сеть и писать устойчивый код с fallback без реальных запросов.

Архитектура

src/
├── models.py          # Todo модель
├── repository.py      # Работа с "БД" (in-memory)
├── cache.py           # Redis кеш
├── quotes_api.py      # HTTP API для цитат
└── todo_service.py    # Бизнес-логика
 
tests/
├── conftest.py        # Общие фикстуры
├── unit/
│   ├── test_repository.py
│   ├── test_cache.py
│   └── test_quotes_api.py
└── integration/
    └── test_todo_service.py

Требования

# Устанавливаем зависимости
pip install pytest pytest-cov freezegun requests-mock

Реализация

1. Модель Todo

# src/models.py
 
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
 
@dataclass
class Todo:
    id: int
    title: str
    completed: bool = False
    created_at: Optional[datetime] = None
    motivation_quote: Optional[str] = None
 
    def mark_completed(self):
        """Отметить как выполненное"""
 
        self.completed = True
 
    def add_motivation(self, quote: str):
        """Добавить мотивационную цитату"""
 
        self.motivation_quote = quote

2. Repository (in-memory БД)

# src/repository.py
 
from typing import Dict, List, Optional
from src.models import Todo
 
class TodoRepository:
    """In-memory хранилище Todo"""
 
    def __init__(self):
        self._storage: Dict[int, Todo] = {}
        self._next_id = 1
 
    def create(self, title: str) -> Todo:
        """Создаёт новый Todo"""
 
        from datetime import datetime
 
        todo = Todo(
            id=self._next_id,
            title=title,
            created_at=datetime.now()
        )
        self._storage[todo.id] = todo
        self._next_id += 1
        return todo
 
    def get(self, todo_id: int) -> Optional[Todo]:
        """Получает Todo по ID"""
 
        return self._storage.get(todo_id)
 
    def get_all(self) -> List[Todo]:
        """Получает все Todo"""
 
        return list(self._storage.values())
 
    def delete(self, todo_id: int) -> bool:
        """Удаляет Todo"""
 
        if todo_id in self._storage:
            del self._storage[todo_id]
            return True
        return False

3. Redis Cache

# src/cache.py
 
from typing import Optional
import json
 
class RedisCache:
    """Простой кеш (можно заменить на реальный Redis)"""
 
    def __init__(self):
        self._cache: dict = {}
 
    def get(self, key: str) -> Optional[str]:
        """Получает значение из кеша"""
 
        return self._cache.get(key)
 
    def set(self, key: str, value: str, ttl: int = 300):
        """Сохраняет значение в кеш"""
 
        self._cache[key] = value
 
    def delete(self, key: str):
        """Удаляет значение из кеша"""
 
        if key in self._cache:
            del self._cache[key]
 
    def clear(self):
        """Очищает весь кеш"""
 
        self._cache.clear()

4. Quotes API Client

Клиент делает вид, что ходит в сервис цитат. На самом деле домен нарочно не существует, поэтому без моков запрос упадёт и сработает fallback.

# src/quotes_api.py
 
import requests
 
class QuotesAPIClient:
    """Клиент для получения мотивационных цитат (сервис заведомо фейковый)"""
 
    def __init__(self, base_url: str = "https://quotes.invalid"):
        self.base_url = base_url
 
    def get_random_quote(self) -> str:
        """Получает случайную цитату"""
 
        try:
            response = requests.get(f"{self.base_url}/random", timeout=5)
            response.raise_for_status()
            data = response.json()
            return f"{data['content']}{data['author']}"
        except requests.RequestException:
            # Сервис не существует, поэтому чаще всего попадём сюда без моков
            return "Stay motivated!"  # Fallback

5. Todo Service (бизнес-логика)

# src/todo_service.py
 
from typing import List, Optional
from src.models import Todo
from src.repository import TodoRepository
from src.cache import RedisCache
from src.quotes_api import QuotesAPIClient
 
class TodoService:
    """Сервис для работы с Todo"""
 
    def __init__(
        self,
        repository: TodoRepository,
        cache: RedisCache,
        quotes_api: QuotesAPIClient
    ):
        self.repository = repository
        self.cache = cache
        self.quotes_api = quotes_api
 
    def create_todo(self, title: str, add_motivation: bool = False) -> Todo:
        """Создаёт новый Todo с опциональной мотивацией"""
 
        todo = self.repository.create(title)
 
        if add_motivation:
            quote = self.quotes_api.get_random_quote()
            todo.add_motivation(quote)
 
        # Инвалидируем кеш списка
        self.cache.delete("todos:all")
 
        return todo
 
    def get_todo(self, todo_id: int) -> Optional[Todo]:
        """Получает Todo (с кешированием)"""
 
        # Проверяем кеш
        cache_key = f"todo:{todo_id}"
        cached = self.cache.get(cache_key)
 
        if cached:
            import json
            data = json.loads(cached)
            return Todo(**data)
 
        # Если нет в кеше — берём из БД
        todo = self.repository.get(todo_id)
 
        if todo:
            # Сохраняем в кеш
            import json
            from dataclasses import asdict
            self.cache.set(cache_key, json.dumps(asdict(todo)))
 
        return todo
 
    def get_all_todos(self) -> List[Todo]:
        """Получает все Todo (с кешированием)"""
 
        # Проверяем кеш
        cached = self.cache.get("todos:all")
 
        if cached:
            import json
            data = json.loads(cached)
            return [Todo(**item) for item in data]
 
        # Если нет в кеше — берём из БД
        todos = self.repository.get_all()
 
        # Сохраняем в кеш
        import json
        from dataclasses import asdict
        self.cache.set("todos:all", json.dumps([asdict(t) for t in todos]))
 
        return todos
 
    def complete_todo(self, todo_id: int) -> bool:
        """Отмечает Todo как выполненное"""
 
        todo = self.repository.get(todo_id)
 
        if not todo:
            return False
 
        todo.mark_completed()
 
        # Инвалидируем кеш
        self.cache.delete(f"todo:{todo_id}")
        self.cache.delete("todos:all")
 
        return True

Тесты

conftest.py: общие фикстуры

# tests/conftest.py
 
import pytest
from src.repository import TodoRepository
from src.cache import RedisCache
from src.quotes_api import QuotesAPIClient
from src.todo_service import TodoService
 
@pytest.fixture
def repository():
    """Чистый repository для каждого теста"""
 
    return TodoRepository()
 
@pytest.fixture
def cache():
    """Чистый кеш для каждого теста"""
 
    cache = RedisCache()
    yield cache
    cache.clear()
 
@pytest.fixture
def quotes_api():
    """Реальный API-клиент"""
 
    return QuotesAPIClient()
 
@pytest.fixture
def mock_quotes_api():
    """Мок API-клиента для unit-тестов"""
 
    from unittest.mock import Mock
    mock = Mock(spec=QuotesAPIClient)
    mock.get_random_quote.return_value = "Test quote — Test Author"
    return mock
 
@pytest.fixture
def todo_service(repository, cache, mock_quotes_api):
    """TodoService с моками"""
 
    return TodoService(
        repository=repository,
        cache=cache,
        quotes_api=mock_quotes_api
    )

Unit-тесты: Repository

# tests/unit/test_repository.py
 
import pytest
from freezegun import freeze_time
from datetime import datetime
 
@freeze_time("2025-01-15 10:00:00")
def test_create_todo(repository):
    """Тест создания Todo"""
 
    todo = repository.create("Buy milk")
 
    assert todo.id == 1
    assert todo.title == "Buy milk"
    assert todo.completed == False
    assert todo.created_at == datetime(2025, 1, 15, 10, 0, 0)
 
def test_get_todo(repository):
    """Тест получения Todo"""
 
    created = repository.create("Buy milk")
 
    todo = repository.get(created.id)
 
    assert todo.title == "Buy milk"
 
def test_get_nonexistent_todo(repository):
    """Тест получения несуществующего Todo"""
 
    todo = repository.get(999)
 
    assert todo is None
 
def test_get_all_todos(repository):
    """Тест получения всех Todo"""
 
    repository.create("Task 1")
    repository.create("Task 2")
    repository.create("Task 3")
 
    todos = repository.get_all()
 
    assert len(todos) == 3
    assert todos[0].title == "Task 1"
    assert todos[2].title == "Task 3"
 
def test_delete_todo(repository):
    """Тест удаления Todo"""
 
    todo = repository.create("Delete me")
 
    result = repository.delete(todo.id)
 
    assert result == True
    assert repository.get(todo.id) is None
 
def test_delete_nonexistent_todo(repository):
    """Тест удаления несуществующего Todo"""
 
    result = repository.delete(999)
 
    assert result == False

Unit-тесты: Cache

# tests/unit/test_cache.py
 
def test_cache_get_set(cache):
    """Тест сохранения и получения"""
 
    cache.set("key1", "value1")
 
    result = cache.get("key1")
 
    assert result == "value1"
 
def test_cache_get_nonexistent(cache):
    """Тест получения несуществующего ключа"""
 
    result = cache.get("nonexistent")
 
    assert result is None
 
def test_cache_delete(cache):
    """Тест удаления из кеша"""
 
    cache.set("key1", "value1")
    cache.delete("key1")
 
    result = cache.get("key1")
 
    assert result is None
 
def test_cache_clear(cache):
    """Тест очистки кеша"""
 
    cache.set("key1", "value1")
    cache.set("key2", "value2")
 
    cache.clear()
 
    assert cache.get("key1") is None
    assert cache.get("key2") is None

Unit-тесты: Quotes API (с моком)

# tests/unit/test_quotes_api.py
 
import pytest
import requests_mock
 
def test_get_random_quote_success():
    """Тест успешного получения цитаты"""
 
    with requests_mock.Mocker() as m:
        m.get("https://quotes.invalid/random", json={
            "content": "Be yourself",
            "author": "Oscar Wilde"
        })
 
        client = QuotesAPIClient()
        quote = client.get_random_quote()
 
        assert quote == "Be yourself — Oscar Wilde"
 
def test_get_random_quote_network_error():
    """Тест обработки сетевой ошибки"""
 
    with requests_mock.Mocker() as m:
        m.get("https://quotes.invalid/random", exc=requests.ConnectionError)
 
        client = QuotesAPIClient()
        quote = client.get_random_quote()
 
        assert quote == "Stay motivated!"  # Fallback
 
def test_get_random_quote_timeout():
    """Тест обработки timeout"""
 
    with requests_mock.Mocker() as m:
        m.get("https://quotes.invalid/random", exc=requests.Timeout)
 
        client = QuotesAPIClient()
        quote = client.get_random_quote()
 
        assert quote == "Stay motivated!"

Integration-тесты: TodoService

# tests/integration/test_todo_service.py
 
import pytest
 
def test_create_todo_without_motivation(todo_service):
    """Тест создания Todo без мотивации"""
 
    todo = todo_service.create_todo("Buy milk")
 
    assert todo.id == 1
    assert todo.title == "Buy milk"
    assert todo.motivation_quote is None
 
def test_create_todo_with_motivation(todo_service):
    """Тест создания Todo с мотивацией"""
 
    todo = todo_service.create_todo("Learn pytest", add_motivation=True)
 
    assert todo.motivation_quote == "Test quote — Test Author"
    # Проверяем что quotes_api был вызван
    todo_service.quotes_api.get_random_quote.assert_called_once()
 
def test_get_todo_from_cache(todo_service):
    """Тест получения Todo из кеша"""
 
    # Создаём Todo
    created = todo_service.create_todo("Task 1")
 
    # Первый вызов — из БД
    todo1 = todo_service.get_todo(created.id)
    assert todo1.title == "Task 1"
 
    # Второй вызов — из кеша (репозиторий не вызывается)
    todo2 = todo_service.get_todo(created.id)
    assert todo2.title == "Task 1"
 
def test_get_all_todos_cache_invalidation(todo_service):
    """Тест инвалидации кеша при создании"""
 
    # Получаем пустой список (кешируется)
    todos1 = todo_service.get_all_todos()
    assert len(todos1) == 0
 
    # Создаём Todo (кеш инвалидируется)
    todo_service.create_todo("New task")
 
    # Получаем обновлённый список
    todos2 = todo_service.get_all_todos()
    assert len(todos2) == 1
 
def test_complete_todo(todo_service):
    """Тест отметки Todo как выполненного"""
 
    todo = todo_service.create_todo("Task")
 
    result = todo_service.complete_todo(todo.id)
 
    assert result == True
    # Проверяем что Todo обновлён
    updated = todo_service.repository.get(todo.id)
    assert updated.completed == True
 
def test_complete_nonexistent_todo(todo_service):
    """Тест отметки несуществующего Todo"""
 
    result = todo_service.complete_todo(999)
 
    assert result == False

Запуск и Coverage

pytest.ini: конфигурация

# pytest.ini
 
[pytest]
testpaths = tests
addopts =
    -v
    --tb=short
    --cov=src
    --cov-report=term-missing
    --cov-report=html
    --strict-markers
 
markers =
    unit: Unit-тесты
    integration: Integration-тесты

Запуск всех тестов

pytest

Результат:

========================= test session starts ==========================
collected 18 items
 
tests/unit/test_repository.py::test_create_todo PASSED          [  5%]
tests/unit/test_repository.py::test_get_todo PASSED             [ 11%]
tests/unit/test_repository.py::test_get_nonexistent_todo PASSED [ 16%]
tests/unit/test_repository.py::test_get_all_todos PASSED        [ 22%]
tests/unit/test_repository.py::test_delete_todo PASSED          [ 27%]
tests/unit/test_repository.py::test_delete_nonexistent_todo ... [ 33%]
tests/unit/test_cache.py::test_cache_get_set PASSED             [ 38%]
tests/unit/test_cache.py::test_cache_get_nonexistent PASSED     [ 44%]
tests/unit/test_cache.py::test_cache_delete PASSED              [ 50%]
tests/unit/test_cache.py::test_cache_clear PASSED               [ 55%]
tests/unit/test_quotes_api.py::test_get_random_quote_success ... [ 61%]
tests/unit/test_quotes_api.py::test_get_random_quote_network ... [ 66%]
tests/unit/test_quotes_api.py::test_get_random_quote_timeout ... [ 72%]
tests/integration/test_todo_service.py::test_create_todo_wit... [ 77%]
tests/integration/test_todo_service.py::test_create_todo_wit... [ 83%]
tests/integration/test_todo_service.py::test_get_todo_from_c... [ 88%]
tests/integration/test_todo_service.py::test_get_all_todos_c... [ 94%]
tests/integration/test_todo_service.py::test_complete_todo P... [100%]
 
---------- coverage: platform darwin, python 3.11 -----------
Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/models.py              10      0   100%
src/repository.py          20      0   100%
src/cache.py               12      0   100%
src/quotes_api.py          10      1    90%   18
src/todo_service.py        35      2    94%   45, 67
-----------------------------------------------------
TOTAL                      87      3    97%
 
==================== 18 passed in 1.23s ============================

97% coverage!

HTML отчёт

open htmlcov/index.html

Поздравляю! 🎉

Вы завершили курс pytest-junior!

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

  • Мокирование — HTTP (requests-mock), время (freezegun), файлы (mock_open)
  • unittest.mock — Mock, patch, MagicMock, side_effect
  • Coverage — pytest-cov, HTML отчёты, branch coverage
  • pytest.ini — конфигурация для команды
  • conftest.py — переиспользование фикстур, autouse, scope
  • Реальный проект — Todo-приложение с тестами

Что дальше?

Вы прошли Level 2 (Junior). Следующий шаг — Level 3:

📚 Следующие курсы:

📖 Полезные материалы:

Продолжайте практиковаться:

  1. Добавьте в проект SQLite вместо in-memory БД
  2. Добавьте реальный Redis (см. Docker Compose Guide)
  3. Создайте Flask/FastAPI API (см. Создаём API мотивационных цитат на FastAPI)
  4. Добавьте Docker для тестов
  5. Изучите async версию (см. pytest-async-race-conditions)

Используйте изученное в реальных проектах!

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

Убедитесь что:

  1. Структура проекта правильная (src/, tests/)
  2. Запускаете pytest из корня проекта
  3. Установлены все зависимости: pip install -r requirements.txt
  1. Добавьте тесты для непокрытых строк (см. --cov-report=term-missing)
  2. Используйте HTML отчёт для визуализации
  3. Помните: 80-90% обычно достаточно!

Используйте pytest-asyncio:

pip install pytest-asyncio
@pytest.mark.asyncio
async def test_async_function():
  result = await async_function()
  assert result == "expected"

Используйте pytest-xdist (изучите в Level 3):

pip install pytest-xdist
pytest -n auto  # Автоматическое распределение по CPU

🎉 Поздравляем с завершением курса!

Поделитесь опытом и получите промокод на бесплатный доступ к любому premium-материалу на выбор

Бесплатный доступ к любому premium-материалу на выбор

🎓

Ваш опыт поможет другим студентам

📧

Промокод придет на email в течение 24 часов

Минимум 50 символов

0/50

Мини-проект: Todo с кешем и API — Pytest для джунов: Моки и интеграция — Potapov.me