Тестування FastAPI — pytest, httpx, fixtures
Тестування FastAPI — pytest, httpx, fixtures
Якісний програмний продукт відрізняється від «просто працюючого прототипу» однією важливою характеристикою — передбачуваністю. У світі корпоративної розробки ви не можете дозволити собі сподіватися, що чергова зміна в коді не зламає авторизацію чи логіку підрахунку кошика. Єдиний спосіб гарантувати стабільність системи при постійному рефакторингу та додаванні нових фіч — це автоматизоване тестування.
Розробники, які приходять у Python-екосистему з платформи .NET, уже мають міцну базу знань про життєвий цикл тестів, мокування та інтеграційні тести. Однак інструментарій Python має свою специфіку. Цей матеріал покликаний не лише навчити вас писати тести для FastAPI з нуля, але й провести паралелі з концепціями .NET (xUnit, NUnit, WebApplicationFactory), щоб полегшити перехід та сформувати глибоке розуміння процесів.
1. Філософія тестування у FastAPI та піраміда тестування
Перш ніж писати перший рядок тестового коду, згадаймо класичну концепцію — піраміду тестування. Вона визначає ідеальне співвідношення різних типів тестів у проекті:
У контексті FastAPI-застосунків кожен рівень піраміди має своє практичне втілення:
- Unit-тести: Тестують найменші ізольовані елементи коду. У FastAPI це зазвичай Pydantic-схеми (чи правильно валідуються дані), допоміжні чисті функції (хешування паролів, форматування дат) та окремі сервіси бізнес-логіки без доступу до зовнішніх ресурсів.
- Integration (інтеграційні) тести: Перевіряють, як компоненти системи працюють разом. Це серце тестування FastAPI. Тут ми використовуємо
TestClient(на базіhttpx) для виконання віртуальних HTTP-запитів до наших ендпоінтів, використовуємо тестову базу даних PostgreSQL і перевіряємо middleware та обробку помилок. - E2E-тести: Перевіряють систему від початку до кінця з точки зору кінцевого користувача, зазвичай імітуючи дії у веб-браузері.
Порівняльний аналіз концепцій: C# ↔ Python
Для розробників із досвідом в ASP.NET Core інструментарій Python може здатися незвичним через динамічну природу мови та іншу філософію тестових фреймворків. Порівняймо ключові концепції:
| Концепція у .NET (ASP.NET Core) | Еквівалент у Python (FastAPI / pytest) | Опис різниці |
|---|---|---|
| xUnit / NUnit | pytest | pytest є функціональним фреймворком. Замість класів-фікстур та атрибутів використовуються прості функції та декоратори. |
[Fact] / [Theory] | test_* функції / @pytest.mark.parametrize | В pytest будь-яка функція, яка починається з test_, є тестом. Параметризація робиться через декоратор. |
IClassFixture<T> / Setup-Teardown | pytest Fixtures (yield) | Замість життєвого циклу класів та IDisposable, pytest використовує потужну систему DI-фікстур з ключовим словом yield. |
WebApplicationFactory + HttpClient | TestClient (або AsyncClient з httpx) | FastAPI надає вбудований клієнт, який запускає ASGI-застосунок в пам'яті без підняття реального мережевого порту. |
ConfigureTestServices | app.dependency_overrides | Замість перевизначення сервісів у IoC-контейнері DI через перевизначення провайдерів, FastAPI дозволяє просто підмінити будь-яку залежність через словник перевизначень. |
| Moq / NSubstitute | unittest.mock / pytest-mock | Завдяки динамічній природі Python, мокування не потребує інтерфейсів. Будь-який об'єкт або метод можна "запатчити" під час виконання. |
| Bogus | Faker / Factory Boy | Бібліотеки для генерації реалістичних тестових даних (імена, пошти, дати). |
2. Знайомство з pytest та налаштування середовища
pytest — це стандарт де-факто для тестування в Python. На відміну від стандартної бібліотеки unittest (яка побудована на базі застарілої концепції JUnit з Java), pytest використовує максимально лаконічний, "пітонічний" підхід. Вам не потрібно створювати класи та успадковувати їх від базових тестових класів — достатньо написати звичайну функцію та використати стандартне ключове слово assert.
Встановлення залежностей
Для організації повноцінного тестового середовища нам знадобиться сам фреймворк pytest, клієнт httpx для запитів та утиліти для асинхронного тестування.
pip install pytest pytest-asyncio httpx pytest-cov faker factory-boy
uv add --dev pytest pytest-asyncio httpx pytest-cov faker factory-boy
poetry add -D pytest pytest-asyncio httpx pytest-cov faker factory-boy
Конфігурація pytest
У Python-проектах конфігурація інструментів тестування зазвичай розміщується у файлі pyproject.toml (сучасний стандарт) або pytest.ini. Додамо базові налаштування в pyproject.toml вашого проекту, щоб pytest знав, де шукати тести та як обробляти асинхронні виклики:
[tool.pytest.ini_options]
# Визначає мінімальну версію pytest
minversion = "7.0"
# Шляхи, де pytest шукатиме тести за замовчуванням
testpaths = ["tests"]
# Шаблони для пошуку тестових файлів, класів та функцій
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
# Додає поточну директорію до шляхів пошуку модулів Python
pythonpath = ["."]
# Налаштування виводу та поведінки
# -v: verbose (детальний вивід)
# -ra: показати підсумки для всіх тестів, окрім успішних (passed)
# --strict-markers: заборонити використання незареєстрованих маркерів
addopts = "-v -ra --strict-markers"
# Автоматичне виявлення асинхронних тестів без потреби писати @pytest.mark.asyncio над кожним
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
# Реєстрація користувацьких маркерів
markers = [
"unit: швидкі unit-тести",
"integration: інтеграційні тести, що потребують БД або зовнішніх викликів",
"auth: тести, пов'язані з автентифікацією та безпекою"
]
--strict-markers разом із секцією markers запобігає друкарським помилкам. Якщо ви випадково напишете @pytest.mark.unitt замість @pytest.mark.unit, pytest видасть помилку під час запуску, а не просто проігнорує цей маркер.3. Написання перших Unit-тестів: Валідація Pydantic-схем
Почнемо з найнижчого рівня піраміди — модульних (unit) тестів. Pydantic-схем у FastAPI виконують роль DTO (Data Transfer Objects) та контрактів API. Тестування схем дозволяє переконатися, що бізнес-правила валідації працюють коректно ще до того, як дані потраплять у базу даних чи оброблятимуться ендпоінтом.
Уявімо, що у нас є схема для створення завдання (Task) у нашому додатку TaskForge:
from datetime import datetime, date
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class TaskCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=100, description="Назва завдання")
description: Optional[str] = Field(None, max_length=1000)
due_date: Optional[date] = Field(None, description="Дедлайн виконання")
priority: int = Field(default=1, ge=1, le=5, description="Пріоритет від 1 до 5")
@field_validator("due_date")
@classmethod
def prevent_past_dates(cls, value: Optional[date]) -> Optional[date]:
if value and value < date.today():
raise ValueError("Дедлайн не може бути в минулому")
return value
@model_validator(mode="after")
def validate_title_description(self) -> "TaskCreate":
if self.title.lower() in (self.description or "").lower():
raise ValueError("Опис завдання не повинен повністю містити назву завдання")
return self
Ця схема має кілька правил валідації:
titleмає бути від 3 до 100 символів.priorityобмежений діапазоном від 1 до 5 включно.due_dateне може бути в минулому (custom field validator).- Опис не повинен містити в собі назву (custom model validator).
Створимо файл для тестів валідації: tests/unit/test_task_schemas.py.
Написання тестів за допомогою стандартного assert
На відміну від C# xUnit, де ми пишемо Assert.Equal(expected, actual) або Assert.Throws<ValidationException>(...), у pytest ми пишемо стандартні вирази Python assert == та контекстні менеджери with pytest.raises(ValidationError):
from datetime import date, timedelta
import pytest
from pydantic import ValidationError
from app.schemas.task import TaskCreate
@pytest.mark.unit
def test_task_create_valid_data():
"""Тест успішної валідації з коректними даними."""
valid_data = {
"title": "Learn FastAPI testing",
"description": "Read article about pytest, fixtures and write unit tests.",
"due_date": date.today() + timedelta(days=1),
"priority": 3
}
task = TaskCreate(**valid_data)
assert task.title == valid_data["title"]
assert task.description == valid_data["description"]
assert task.due_date == valid_data["due_date"]
assert task.priority == valid_data["priority"]
@pytest.mark.unit
def test_task_create_title_too_short():
"""Перевірка помилки валідації, якщо назва занадто коротка."""
invalid_data = {
"title": "Ab", # Менше 3 символів
"priority": 1
}
# Контекстний менеджер очікує підняття вказаної помилки
with pytest.raises(ValidationError) as exc_info:
TaskCreate(**invalid_data)
# exc_info.value містить екземпляр ValidationError
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("title",)
assert errors[0]["type"] == "string_too_short"
pytest.raisesКонтекстний менеджер pytest.raises(ValidationError) є аналогом Assert.Throws<T>() в xUnit. Він перехоплює виключення, яке виникає всередині блоку with. Через властивість .value ми можемо отримати деталі виключення (наприклад, список помилок Pydantic) і зробити додаткові перевірки (asserts).Параметризовані тести: @pytest.mark.parametrize
У реальних проектах вам часто потрібно перевірити один і той самий алгоритм або правило валідації на великому наборі різних вхідних даних. Замість того, щоб копіювати один і той самий тестовий метод десять разів із різними параметрами, у тестуванні використовують концепцію параметризованих тестів (або table-driven tests).
В .NET xUnit для цього є пара [Theory] та [InlineData]. У pytest аналогічна логіка реалізована за допомогою вбудованого декоратора @pytest.mark.parametrize.
Розглянемо, як протестувати різні варіанти невалідних даних для нашої схеми TaskCreate:
# Продовження файлу tests/unit/test_task_schemas.py
@pytest.mark.unit
@pytest.mark.parametrize(
"invalid_priority",
[0, 6, -1, 10]
)
def test_task_create_invalid_priority(invalid_priority):
"""Перевірка обмежень для поля priority (має бути від 1 до 5)."""
invalid_data = {
"title": "Learn pytest",
"priority": invalid_priority
}
with pytest.raises(ValidationError) as exc_info:
TaskCreate(**invalid_data)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("priority",)
# Pydantic повертає детальний опис помилки обмеження
assert "greater_than_equal" in errors[0]["type"] or "less_than_equal" in errors[0]["type"]
Декоратор @pytest.mark.parametrize приймає два основних аргументи:
- Рядок з іменами параметрів, які будуть передані у тестову функцію (наприклад,
"invalid_priority"). - Ітерований об'єкт (список, кортеж), де кожен елемент є набором аргументів для одного запуску тесту.
Ми можемо передавати кілька параметрів одночасно. Давайте протестуємо логіку нашого кастомного валідатора, який забороняє вибирати дедлайн у минулому, а також перевіримо поведінку з коректними датами:
# Продовження файлу tests/unit/test_task_schemas.py
@pytest.mark.unit
@pytest.mark.parametrize(
"due_date_value, should_be_valid",
[
(date.today(), True), # Сьогодні — дозволено
(date.today() + timedelta(days=1), True), # Майбутнє — дозволено
(date.today() - timedelta(days=1), False), # Вчора — помилка
(date.today() - timedelta(days=365), False), # Рік тому — помилка
]
)
def test_task_due_date_validation(due_date_value, should_be_valid):
"""Тестування кастомного валідатора prevent_past_dates."""
data = {
"title": "Build a house",
"due_date": due_date_value,
"priority": 1
}
if should_be_valid:
task = TaskCreate(**data)
assert task.due_date == due_date_value
else:
with pytest.raises(ValidationError) as exc_info:
TaskCreate(**data)
errors = exc_info.value.errors()
assert errors[0]["loc"] == ("due_date",)
assert "Дедлайн не може бути в минулому" in errors[0]["msg"]
[Theory]
[InlineData("2026-07-13", true)]
[InlineData("2026-07-14", true)]
[InlineData("2026-07-12", false)]
public void TestDueDate(string dateStr, bool shouldBeValid)
{
var date = DateOnly.Parse(dateStr);
// Логіка тесту...
}
@pytest.mark.parametrize(
"due_date_value, should_be_valid",
[
(date.today(), True),
(date.today() + timedelta(days=1), True),
(date.today() - timedelta(days=1), False),
]
)
def test_due_date(due_date_value, should_be_valid):
# Логіка тесту...
date.today() + timedelta(...)) безпосередньо у параметри декоратора. В C# атрибути [InlineData] приймають лише константні вирази, що змушує розробників використовувати складніші конструкції на кшталт [MemberData] або [ClassData].4. Фікстури (Fixtures) у pytest: Основа Dependency Injection
У традиційних об'єктно-орієнтованих тестових фреймворках (на кшталт xUnit чи MSTest) для підготовки тестового середовища використовуються методи SetUp та TearDown (або конструктор класу та метод Dispose()). Усі тести в межах одного класу ділять ці методи.
Цей підхід має суттєвий недолік — жорстку залежність. Якщо частина тестів у класі потребує підключення до БД, а частина — ні, вам доведеться або створювати підключення для всіх тестів, або розносити їх по різних файлах, що штучно ускладнює структуру проекту.
pytest пропонує принципово іншу модель — фікстури (fixtures). Це функції, які постачають ресурси, дані або сервіси для ваших тестів через механізм впровадження залежностей (Dependency Injection).
Створення базової фікстури
Для створення фікстури використовується декоратор @pytest.fixture. Коли тестова функція приймає аргумент із таким самим ім'ям, як і фікстура, pytest автоматично викликає функцію-фікстуру і передає її результат як цей аргумент.
Уявімо, що нам потрібен тестовий словник із типовими даними користувача для створення профілю:
import pytest
@pytest.fixture
def sample_user_data():
"""Фікстура, що повертає початкові дані для тестування користувачів."""
return {
"email": "student@kostyl.dev",
"username": "kostyl_dev",
"password": "SuperSecretPassword123"
}
# Використання фікстури в тесті
def test_user_data_keys(sample_user_data):
assert "email" in sample_user_data
assert sample_user_data["username"] == "kostyl_dev"
Області видимості (Fixture Scopes)
Ресурси, які ми створюємо для тестів, мають різну «вартість». Наприклад, генерація словника в пам'яті відбувається миттєво, а от створення підключення до бази даних PostgreSQL або підняття тестового сервера — операції важкі.
pytest дозволяє контролювати життєвий цикл фікстури за допомогою параметра scope:
class TestSomething).Життєвий цикл: Setup та Teardown через yield
Часто фікстурі потрібно не просто створити ресурс, але й прибрати за собою після завершення тесту (закрити файловий потік, очистити таблиці БД, видалити тимчасові файли).
Для цього замість ключового слова return використовується yield. Код до yield виконується до запуску тесту (Setup), а код після yield — після завершення тесту (Teardown), навіть якщо тест завершився з помилкою.
import os
import pytest
@pytest.fixture(scope="function")
def temp_file():
# Setup: створюємо тимчасовий файл
file_path = "temp_test_data.txt"
with open(file_path, "w") as f:
f.write("Test content")
# Повертаємо шлях до файлу в тест
yield file_path
# Teardown: видаляємо файл після завершення тесту
if os.path.exists(file_path):
os.remove(file_path)
def test_read_temp_file(temp_file):
with open(temp_file, "r") as f:
content = f.read()
assert content == "Test content"
conftest.py — Джерело істини
Де ви маєте зберігати свої фікстури? Якщо ви напишете їх у файлі з тестами, вони будуть доступні лише в межах цього файлу.
Для того, щоб зробити фікстури глобальними та доступними для багатьох файлів без явного імпорту, pytest використовує спеціальний файл з назвою conftest.py.
pytest автоматично сканує каталог з тестами, знаходить усі файли conftest.py і реєструє описані в них фікстури. Ви можете створювати кілька таких файлів на різних рівнях вкладеності папок, створюючи ієрархію областей видимості:
tests/
│
├── conftest.py # Глобальні фікстури (тестова сесія, конфіги, клієнт)
│
├── unit/
│ ├── conftest.py # Фікстури суто для unit-тестів
│ └── test_schemas.py
│
└── integration/
├── conftest.py # Фікстури для роботи з БД, перевизначення DI
└── test_endpoints.py
5. Інтеграційне тестування ендпоінтів: FastAPI TestClient та HTTPX
Коли ми переходимо від модульних тестів до інтеграційних, нашою метою стає перевірка поведінки системи в цілому. Ми хочемо перевірити, як наші ендпоінти реагують на HTTP-запити: чи правильно працюють роутери, чи спрацьовують потрібні dependency injections (залежності), чи коректно обробляються виключення (exceptions) і чи повертаються правильні HTTP-статуси та JSON-тіло відповіді.
Як працює TestClient під капотом
У світі ASP.NET Core для створення інтеграційних тестів використовується клас WebApplicationFactory<TStartup>, який розгортає ваш додаток в оперативній пам'яті (in-memory TestServer) і надає об'єкт HttpClient для надсилання запитів до нього.
FastAPI пропонує дуже схожий і навіть простіший механізм за допомогою вбудованого інструменту fastapi.testclient.TestClient.
Під капотом TestClient використовує популярну бібліотеку HTTPX — сучасний HTTP-клієнт для Python, який підтримує як синхронні, так і асинхронні запити.
Найважливіша особливість TestClient полягає в тому, що він не запускає реальний веб-сервер (як-от Uvicorn) на мережевому порту (наприклад, http://127.0.0.1:8000). Замість цього він використовує ASGI-стандарт. Клієнт напряму комунікує з вашим FastAPI-додатком через внутрішні виклики функцій в оперативній пам'яті. Це робить виконання тестів надзвичайно швидким — ви можете прогнати сотні API-тестів за лічені секунди.
Синхронний TestClient проти асинхронного AsyncClient
FastAPI підтримує як класичні синхронні ендпоінти (def), так і сучасні асинхронні (async def).
Для тестування синхронних запитів підходить вбудований TestClient. Проте, якщо ваш додаток активно використовує асинхронну роботу (наприклад, асинхронний драйвер SQLAlchemy asyncpg чи асинхронний клієнт Redis), використання синхронного TestClient може призвести до помилок з блокуванням Event Loop. У таких випадках потрібно використовувати асинхронний клієнт — httpx.AsyncClient разом із плагіном pytest-asyncio.
Порівняймо налаштування обох клієнтів у нашому глобальному файлі tests/conftest.py:
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from app.main import app # Імпорт вашого FastAPI застосунку
# 1. Синхронний клієнт для простих тестів
@pytest.fixture(scope="module")
def client():
"""Фікстура для створення синхронного тестового клієнта."""
# Context manager гарантує спрацювання подій startup/shutdown застосунку
with TestClient(app) as c:
yield c
# 2. Асинхронний клієнт для тестування асинхронних ендпоінтів
@pytest.fixture(scope="module")
async def async_client():
"""Фікстура для створення асинхронного тестового клієнта."""
# AsyncClient приймає наш app і base_url
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
with при створенні TestClient?Коли ви використовуєте with TestClient(app) as client, FastAPI запускає ваші життєві події (Lifespan events) — логіку, яка прописана в @app.lifespan (наприклад, ініціалізація пулу підключень бази даних чи завантаження ML-моделей). Якщо ви створите клієнт просто як client = TestClient(app), ці події не запустяться, і ваші тести впадуть через відсутність необхідних ресурсів.Написання перших тестів для ендпоінтів
Створимо найпростіший ендпоінт перевірки працездатності системи (Health Check) в app/main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health_check():
return {"status": "healthy", "version": "1.0.0"}
Тепер напишемо для нього синхронний тест у файлі tests/integration/test_health.py:
import pytest
@pytest.mark.integration
def test_health_check_sync(client):
"""Синхронний тест ендпоінту /health."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy", "version": "1.0.0"}
Для асинхронного тесту того самого ендпоінту ми маємо позначити тест як асинхронний і використати await:
# Продовження файлу tests/integration/test_health.py
@pytest.mark.integration
async def test_health_check_async(async_client):
"""Асинхронний тест ендпоінту /health за допомогою AsyncClient."""
response = await async_client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
Зверніть увагу на об'єкт відповіді (response). Він є екземпляром класу httpx.Response і надає такі корисні властивості та методи:
json.JSONDecodeError.response.headers["content-type"]).6. Робота з тестовою базою даних (Database Fixtures) та транзакційні відкати
Тестування ендпоінтів, які працюють з базою даних, є однією з найскладніших задач. Тут постає серйозний виклик: як зробити так, щоб тести не залежали один від одного та виконувалися швидко?
Якщо перший тест створює завдання з назвою "Task 1", а другий тест намагається отримати список усіх завдань і очікує там порожній список — другий тест впаде через залишки даних від першого.
Стратегії очищення бази даних
В екосистемі .NET розробники використовують кілька підходів:
InMemoryDatabase(швидко, але не підтримує специфічні фічі на кшталт сирих SQL-запитів чи JSONB-колон PostgreSQL).Respawn— бібліотека, яка очищає таблиці (виконуєTRUNCATE) перед або після кожного тесту.
У Python найбільш елегантним, надійним та швидким підходом є транзакційний відкат (Transaction Rollback).
Суть підходу:
- Перед стартом тесту ми відкриваємо транзакцію в базі даних.
- Під час тесту всі операції запису (INSERT, UPDATE, DELETE) виконуються в межах цієї транзакції.
- Після завершення тесту (неважливо, успішного чи ні) ми робимо ROLLBACK цієї транзакції.
- Дані ніколи фізично не зберігаються в БД назавжди, і кожен тест бачить абсолютно чисту схему.
Реалізація фікстур бази даних у conftest.py
Давайте реалізуємо цей механізм за допомогою SQLAlchemy та FastAPI. Ми будемо використовувати асинхронний драйвер.
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.database import Base, get_db
from app.main import app
# Використовуємо окрему тестову базу даних (можна PostgreSQL або SQLite в пам'яті для швидкості)
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/test_taskforge"
# Створюємо тестовий Engine
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestingSessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@pytest.fixture(scope="session", autouse=True)
async def setup_test_db():
"""Фікстура, що створює схему БД перед тестами та видаляє її після."""
async with engine.begin() as conn:
# Створюємо всі таблиці
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
# Видаляємо всі таблиці після завершення тестової сесії
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function")
async def db_session() -> AsyncSession:
"""Фікстура, яка створює сесію БД для кожного тесту та робить rollback наприкінці."""
connection = await engine.connect()
# Починаємо зовнішню транзакцію
transaction = await connection.begin()
# Створюємо сесію, прив'язану до цього з'єднання
session = TestingSessionLocal(bind=connection)
yield session
# Закриваємо сесію та відкочуємо транзакцію
await session.close()
await transaction.rollback()
await connection.close()
scope="session" для setup_test_db та scope="function" для db_session?Створення таблиць (create_all) — це повільна операція, яка займає сотні мілісекунд. Робити це перед кожним тестом недоцільно. Тому ми робимо це один раз на початку всієї тестової сесії (scope="session").
А от саму сесію та транзакцію ми створюємо окремо для кожного тесту (scope="function"), щоб гарантувати ізоляцію даних та відкат змін після кожного окремого тесту.7. Dependency Overrides: Перевизначення залежностей у FastAPI
Тепер, коли у нас є фікстура db_session, яка вміє відкочувати транзакції, постає питання: як змусити FastAPI використовувати саме цю тестову сесію замість робочої під час виконання HTTP-запитів через TestClient?
В ASP.NET Core для цього довелося б налаштовувати WebApplicationFactory та переписувати сервіси в DI-контейнері:
// Перевизначення у WebApplicationFactory
builder.ConfigureServices(services => {
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TestDb"));
});
У FastAPI впровадження залежностей є частиною самого фреймворку, а не зовнішнього IoC-контейнера. Перевизначити будь-яку залежність (наприклад, функцію get_db, яка повертає сесію бази даних) можна через один простий словник: app.dependency_overrides.
Давайте створимо фікстуру тестового клієнта, яка автоматично підміняє залежність від бази даних:
# Продовження tests/conftest.py
from httpx import AsyncClient
# Наш реальний провайдер сесії БД, який використовується в ендпоінтах
# Наприклад:
# async def get_db():
# async with SessionLocal() as session:
# yield session
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession):
"""Фікстура клієнта, яка підміняє робочу БД на тестову для кожного запиту."""
# Створюємо функцію-заглушку, яка повертатиме нашу тестову сесію
async def _override_get_db():
yield db_session
# Записуємо перевизначення в словник FastAPI
app.dependency_overrides[get_db] = _override_get_db
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
# ВАЖЛИВО: Очищуємо перевизначення після тесту, щоб інші тести не постраждали
app.dependency_overrides.clear()
Тепер давайте подивимось, як написати інтеграційний тест для створення завдання (Task) через API:
import pytest
from datetime import date
@pytest.mark.integration
async def test_create_task_endpoint(client):
"""Перевірка успішного створення завдання через POST ендпоінт."""
payload = {
"title": "Write unit tests",
"description": "Cover all endpoints with pytest",
"due_date": str(date.today()),
"priority": 2
}
response = await client.post("/api/tasks", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] is not None
assert data["title"] == payload["title"]
assert data["priority"] == payload["priority"]
@pytest.mark.integration
async def test_create_task_validation_error(client):
"""Перевірка обробки невалідних даних при створенні завдання."""
# Пріоритет поза межами дозволеного [1..5]
payload = {
"title": "Invalid task",
"priority": 99
}
response = await client.post("/api/tasks", json=payload)
assert response.status_code == 422 # Unprocessable Entity
data = response.json()
assert "detail" in data
# Перевіряємо локацію помилки валідації
assert data["detail"][0]["loc"] == ["body", "priority"]
8. Мокування (Mocking): Ізоляція від зовнішніх сервісів
У ідеальному світі кожен тест мав би запускатися у повному середовищі з реальними інтеграціями. Проте на практиці ваші API часто взаємодіють із системами, які ви не контролюєте або які працюють повільно:
- Платіжні шлюзи (Stripe, LiqPay) — ми не можемо знімати реальні гроші під час тестів.
- Поштові сервіси та SMS-шлюзи (SendGrid, Twilio) — ми не хочемо спамити користувачів тестовими листами.
- Зовнішні API сторонніх компаній.
Для ізоляції нашого коду від цих сервісів використовується мокування (mocking) — створення об'єктів-заглушок (mocks), які імітують поведінку реальних компонентів і повертають заздалегідь визначені результати.
Філософія мокування: Python ↔ .NET
В C# розробники звикли створювати інтерфейси (IEmailService) і використовувати бібліотеки на кшталт Moq або NSubstitute для генерації заглушок у рантаймі:
var emailMock = new Mock<IEmailService>();
emailMock.Setup(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
Python — це мова з динамічною типізацією. Тут нам не потрібні інтерфейси, щоб замінити один об'єкт іншим. Ми можемо буквально «на льоту» підмінити будь-яку функцію, метод чи цілий клас у пам'яті під час виконання тесту.
У стандартній бібліотеці Python для цього є модуль unittest.mock. Проте при роботі з pytest набагато зручніше використовувати плагін pytest-mock, який надає спеціальну фікстуру mocker. Вона автоматично прибирає (скасовує) всі підміни після завершення тесту, запобігаючи витоку заглушок в інші тести.
Практичний приклад мокування
Уявімо, що при створенні завдання ми маємо відправляти email-повідомлення виконавцю за допомогою сервісу EmailService:
import asyncio
class EmailService:
async def send_notification(self, email: str, subject: str, body: str) -> bool:
# Симуляція мережевого запиту до SendGrid
await asyncio.sleep(2)
print(f"Email sent to {email}")
return True
Ендпоінт у нашому FastAPI додатку викликає цей сервіс:
from fastapi import FastAPI, Depends
from app.services.email import EmailService
app = FastAPI()
email_service = EmailService()
@app.post("/tasks/assign")
async def assign_task(email: str):
# Якась логіка...
success = await email_service.send_notification(
email=email,
subject="Нове завдання",
body="Вам призначено нове завдання у TaskForge"
)
return {"status": "assigned", "notified": success}
Для тестування цього ендпоінту ми не хочемо реально чекати 2 секунди та викликати send_notification. Напишемо тест із використанням фіктури mocker:
import pytest
@pytest.mark.integration
async def test_assign_task_with_mocked_email(client, mocker):
"""Тест ендпоінту assign з мокуванням EmailService."""
# 1. Замінюємо реальний метод send_notification на Mock-об'єкт
# Шлях вказується відносно того, ДЕ цей об'єкт використовується під час виконання
mock_send = mocker.patch(
"app.main.email_service.send_notification",
return_value=True # Що має повернути метод при виклику
)
# 2. Виконуємо запит через тестовий клієнт
response = await client.post("/tasks/assign?email=student@kostyl.dev")
# 3. Перевіряємо відповідь API
assert response.status_code == 200
assert response.json() == {"status": "assigned", "notified": True}
# 4. Перевіряємо, чи був викликаний наш mock і з якими аргументами
mock_send.assert_called_once_with(
email="student@kostyl.dev",
subject="Нове завдання",
body="Вам призначено нове завдання у TaskForge"
)
mocker.patch працює шляхом підміни імені в конкретному просторі імен. Ви повинні патчити об'єкт у тому модулі, куди він імпортований і де викликається, а не там, де він визначений.
Якщо ви напишете mocker.patch("app.services.email.EmailService.send_notification"), а в app/main.py вже імпортовано email_service, патч не спрацює, бо main вже тримає посилання на оригінальний об'єкт. Правильний шлях — саме "app.main.email_service.send_notification".9. Генерація тестових даних з Faker та Factory Boy
Коли кількість тестів у проекті зростає до сотень, написання тестових даних вручну (hardcoding на кшталт "test_user@example.com", "Task title 1") стає серйозною проблемою:
- Тести стають крихкими (зміна унікального обмеження в БД ламає інші тести, бо дані повторюються).
- Тестові сценарії виглядають нереалістично.
- Код тестів розростається через постійне створення великих словників-payload-ів.
В .NET для вирішення цієї проблеми використовують бібліотеку Bogus. У Python найкращим вибором є тандем Faker та Factory Boy.
Налаштування фабрик
Створимо фабрику для генерації завдань (моделі SQLAlchemy Task):
import factory
from factory import Faker
from app.models.task import Task # Ваша SQLAlchemy модель завдання
from tests.conftest import TestingSessionLocal # Тестова сесія
class TaskFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Task
# Вказуємо тестову сесію для збереження об'єктів у БД
sqlalchemy_session = None # будемо динамічно передавати в сесії
sqlalchemy_session_persistence = "commit"
# Декларуємо поля та правила їх генерації
id = factory.Sequence(lambda n: n + 1)
# Використовуємо Faker для генерації реалістичних назв
title = Faker("sentence", nb_words=4)
description = Faker("paragraph", nb_sentences=3)
priority = factory.Iterator([1, 2, 3, 4, 5])
due_date = factory.LazyFunction(date.today)
Тепер ми можемо використовувати цю фабрику в наших тестах. Для цього додамо зручну фікстуру в conftest.py, яка зв'язуватиме фабрику з нашою тестовою сесією транзакції:
# Додаємо в tests/conftest.py
from tests.factories import TaskFactory
@pytest.fixture
def task_factory(db_session):
"""Фікстура для створення завдань у тестовій БД."""
# Зв'язуємо фабрику з поточною тестовою сесією
TaskFactory._meta.sqlalchemy_session = db_session
return TaskFactory
Використання фабрики в тестах
Завдяки фабриці, створення тестових записів у базі даних стає неймовірно простим:
import pytest
@pytest.mark.integration
async def test_get_all_tasks(client, task_factory):
"""Тест отримання списку завдань."""
# Створюємо 3 завдання в тестовій БД одним рядком
task_factory.create_batch(3)
response = await client.get("/api/todos")
assert response.status_code == 200
data = response.json()
assert len(data) == 3
# Перевіряємо, що унікальні назви згенерувалися
assert data[0]["title"] != data[1]["title"]
Якщо нам потрібно перевизначити певні поля для конкретного тесту, ми можемо передати їх як іменовані аргументи:
# Продовження файлу tests/integration/test_tasks_query.py
@pytest.mark.integration
async def test_get_task_by_id(client, task_factory):
"""Тест отримання конкретного завдання за ID."""
# Створюємо завдання з фіксованою назвою
task = task_factory.create(title="Specific Task Title")
response = await client.get(f"/api/todos/{task.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == task.id
assert data["title"] == "Specific Task Title"
10. Покриття тестів кодом (Code Coverage) з pytest-cov
Після того, як ви написали десятки тестів, виникає питання: яка частина вашого коду реально перевіряється цими тестами? Чи покрили ви всі гілки розгалужень (if / else) в middleware та сервісах?
Для аналізу покриття коду у світі Python використовується інструмент coverage, а для інтеграції з pytest — плагін pytest-cov.
Запуск тестів з аналізом покриття
Для запуску тестування з генерацією звіту про покриття використовується прапорець --cov:
Звіт містить такі колонки:
- Stmts: Загальна кількість інструкцій (рядків коду) у файлі.
- Miss: Кількість рядків коду, які жодного разу не виконувалися під час запуску тестів.
- Cover: Процентне відношення покриття.
Детальні звіти та поріг покриття
Ви можете згенерувати інтерактивний HTML-звіт, який дозволить побачити конкретні рядки коду, що не були покриті:
pytest --cov=app --cov-report=html tests/
Це створить папку htmlcov/ з веб-сайтом звіту. Відкрийте htmlcov/index.html у браузері.
Також корисно налаштувати автоматичне падіння тестів, якщо загальне покриття є нижчим за певний поріг (наприклад, 80%). Це спонукатиме команду писати тести для нових фіч:
# Додаємо в конфігурацію pytest в pyproject.toml
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers --cov=app --cov-fail-under=80 --cov-report=term-missing"
Параметр --cov-fail-under=80 змусить команду pytest завершитися з помилкою (exit code != 0), якщо загальний рівень покриття впаде нижче 80%.
11. CI/CD Інтеграція: Автоматична перевірка в GitHub Actions
Щоб ваші тести реально приносили користь, вони мають виконуватися автоматично на кожен Pull Request у вашому репозиторії. Тільки так можна бути впевненим, що нова гілка коду не зламає існуючий функціонал.
Створимо файл конфігурації GitHub Actions .github/workflows/ci.yml. Наш pipeline буде виконувати три етапи:
- Linting — перевірка стилю коду за допомогою надшвидкого інструменту
ruff. - Type Checking — статична перевірка типів через
mypy. - Testing — запуск тестів з аналізом покриття та підняттям тимчасової бази даних PostgreSQL.
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
# Піднімаємо тестову базу даних PostgreSQL за допомогою Docker Compose у сервісах GitHub Actions
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_taskforge
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
# Перевірка готовності БД
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # Кешування залежностей для прискорення CI
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff mypy pytest pytest-asyncio pytest-cov pytest-mock httpx faker factory-boy sqlalchemy asyncpg pydantic python-multipart
- name: Run Ruff Linter
run: ruff check app/ tests/
- name: Run Ruff Formatter check
run: ruff format --check app/ tests/
- name: Run Mypy Type Checker
run: mypy app/
- name: Run Tests with Coverage
env:
# Передаємо рядок підключення до тестової БД в оточення CI
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_taskforge
run: pytest
12. Практичний проект від А до Я: Тестування TaskForge
Щоб ви могли закріпити теорію на практиці, ми розберемо створення міні-проєкту TaskForge з нуля: від написання коду самого API до налаштування тестової інфраструктури, написання фікстур та успішного запуску тестів. Ви зможете скопіювати наведені файли та запустити їх у себе на локальній машині.
Крок 1: Структура проєкту
Створіть на комп'ютері нову папку (наприклад, taskforge-demo/) та організуйте в ній таку структуру файлів:
Крок 2: Конфігурація залежностей (pyproject.toml)
Файл pyproject.toml об'єднує налаштування залежностей та конфігурацію pytest:
[project]
name = "taskforge-demo"
version = "1.0.0"
description = "A demo app to show FastAPI testing from A to Z"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110.0",
"uvicorn>=0.28.0",
"sqlalchemy>=2.0.28",
"aiosqlite>=0.20.0", # Асинхронний SQLite для тестів та локальної розробки
"pydantic[email]>=2.6.4",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0",
"python-multipart>=0.0.32",
]
[dependency-groups]
dev = [
"pytest>=8.1.1",
"pytest-asyncio>=0.23.6",
"pytest-cov>=5.0.0",
"pytest-mock>=3.14.0",
"httpx>=0.27.0",
"faker>=24.3.0",
"factory-boy>=3.3.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
pythonpath = ["."]
addopts = "-v -ra --cov=app --cov-report=term-missing"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Крок 3: Налаштування бази даних (app/database.py)
Для простоти розгортання ми будемо використовувати асинхронний SQLite (sqlite+aiosqlite).
from sqlalchemy import event
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import declarative_base
# Локальна SQLite база даних
DATABASE_URL = "sqlite+aiosqlite:///./taskforge.db"
engine = create_async_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
Base = declarative_base()
# Активація підтримки Foreign Keys для SQLite
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Залежність (Dependency Injection) для ендпоінтів
async def get_db() -> AsyncSession:
async with SessionLocal() as session:
try:
yield session
finally:
await session.close()
Крок 4: Опис моделей (app/models.py)
Нам потрібні дві пов'язані моделі: User (користувач) та Task (завдання).
from sqlalchemy import Column, Integer, String, ForeignKey, Date
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
priority = Column(Integer, default=1)
due_date = Column(Date, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
owner = relationship("User", back_populates="tasks")
Крок 5: Pydantic-схеми (app/schemas.py)
Схеми відповідають за перевірку типів та обмежень на вході та виході з API.
from datetime import date
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6)
class UserResponse(BaseModel):
id: int
email: EmailStr
username: str
class Config:
from_attributes = True
class TokenResponse(BaseModel):
access_token: str
token_type: str
class TaskCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=100)
description: Optional[str] = Field(None, max_length=1000)
priority: int = Field(default=1, ge=1, le=5)
due_date: Optional[date] = Field(None)
@field_validator("due_date")
@classmethod
def prevent_past_dates(cls, value: Optional[date]) -> Optional[date]:
if value and value < date.today():
raise ValueError("Дедлайн не може бути в минулому")
return value
class TaskResponse(BaseModel):
id: int
title: str
description: Optional[str]
priority: int
due_date: Optional[date]
owner_id: int
class Config:
from_attributes = True
Крок 6: Логіка безпеки (app/security.py)
Проста логіка для хешування паролів та формування токенів:
import bcrypt
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
SECRET_KEY = "super-secret-key-for-demo-purposes-only"
ALGORITHM = "HS256"
def get_password_hash(password: str) -> str:
pwd_bytes = password.encode("utf-8")[:72]
salt = bcrypt.gensalt()
return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
pwd_bytes = plain_password.encode("utf-8")[:72]
return bcrypt.checkpw(pwd_bytes, hashed_password.encode("utf-8"))
def create_access_token(data: dict, expires_delta: Optional[int] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=expires_delta or 15)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
Крок 7: Веб-додаток (app/main.py)
Наш API дозволяє реєструватися, авторизуватися та керувати своїми завданнями. Ми також додамо авторизаційний Middleware (через Dependency Injection get_current_user).
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app import models, schemas, security
app = FastAPI(title="TaskForge Demo")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# Перевірка авторизації
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, security.SECRET_KEY, algorithms=[security.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(models.User).where(models.User.email == email))
user = result.scalars().first()
if user is None:
raise credentials_exception
return user
@app.get("/health")
def health():
return {"status": "healthy"}
@app.post("/auth/register", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: schemas.UserCreate, db: AsyncSession = Depends(get_db)):
# Перевірка чи пошта вільна
existing = await db.execute(select(models.User).where(models.User.email == user_data.email))
if existing.scalars().first():
raise HTTPException(status_code=400, detail="Email already registered")
hashed = security.get_password_hash(user_data.password)
new_user = models.User(email=user_data.email, username=user_data.username, hashed_password=hashed)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
@app.post("/auth/login", response_model=schemas.TokenResponse)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.User).where(models.User.email == form_data.username))
user = result.scalars().first()
if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password")
token = security.create_access_token(data={"sub": user.email})
return {"access_token": token, "token_type": "bearer"}
@app.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(task_data: schemas.TaskCreate, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
new_task = models.Task(**task_data.model_dump(), owner_id=current_user.id)
db.add(new_task)
await db.commit()
await db.refresh(new_task)
return new_task
@app.get("/tasks", response_model=list[schemas.TaskResponse])
async def get_tasks(current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.Task).where(models.Task.owner_id == current_user.id))
return result.scalars().all()
Крок 8: Налаштування pytest (tests/conftest.py)
Наш тестовий конфіг налаштовує тестову базу даних SQLite в пам'яті (sqlite+aiosqlite:///:memory:) та підміняє провайдер залежності get_db в нашому додатку:
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.database import Base, get_db
from app.main import app
# SQLite в пам'яті для ізоляції тестових прогонів
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
engine = create_async_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@pytest.fixture(scope="session", autouse=True)
async def setup_db():
"""Створює схему в пам'яті один раз для всієї сесії тестів."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function")
async def db_session() -> AsyncSession:
"""Створює окрему транзакцію для кожного окремого тесту та робить rollback."""
connection = await engine.connect()
transaction = await connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
await session.close()
await transaction.rollback()
await connection.close()
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession) -> AsyncClient:
"""Створює тестовий клієнт із підміненою сесією БД."""
async def _override_get_db():
yield db_session
app.dependency_overrides[get_db] = _override_get_db
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
Крок 9: Фабрики даних (tests/factories.py)
Ми налаштовуємо фабрики для автоматичного наповнення БД користувачами та завданнями:
import factory
from factory import Faker
from app import models
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = models.User
sqlalchemy_session = None # буде налаштовано у фікстурі
sqlalchemy_session_persistence = "commit"
id = factory.Sequence(lambda n: n + 1)
email = Faker("email")
username = Faker("user_name")
hashed_password = "dummy_hashed_password" # не принципово для базових тестів
class TaskFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = models.Task
sqlalchemy_session = None
sqlalchemy_session_persistence = "commit"
id = factory.Sequence(lambda n: n + 1)
title = Faker("sentence", nb_words=3)
description = Faker("paragraph", nb_sentences=2)
priority = factory.Iterator([1, 2, 3])
# Зв'язуємо з UserFactory
owner = factory.SubFactory(UserFactory)
Зв'яжемо ці фабрики з нашою сесією транзакції. Додайте ці фікстури до файлу tests/conftest.py:
# Додайте в кінець tests/conftest.py
from tests.factories import UserFactory, TaskFactory
@pytest.fixture
def user_factory(db_session):
UserFactory._meta.sqlalchemy_session = db_session
return UserFactory
@pytest.fixture
def task_factory(db_session):
TaskFactory._meta.sqlalchemy_session = db_session
return TaskFactory
Крок 10: Написання тестів (tests/test_auth.py та tests/test_tasks.py)
Напишемо перевірки для наших ендпоінтів.
Файл тестів автентифікації:
import pytest
async def test_health_endpoint(client):
"""Швидка перевірка доступності нашого API."""
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
async def test_register_user_success(client):
"""Тестування успішної реєстрації."""
payload = {
"email": "student@kostyl.dev",
"username": "student_dev",
"password": "SecretPassword123"
}
response = await client.post("/auth/register", json=payload)
assert response.status_code == 201
data = response.json()
assert data["email"] == payload["email"]
assert data["username"] == payload["username"]
assert "id" in data
async def test_register_duplicate_email(client, user_factory):
"""Спроба реєстрації з існуючим email має викликати помилку 400."""
# Створюємо користувача в базі даних через фабрику
user_factory.create(email="duplicate@kostyl.dev")
payload = {
"email": "duplicate@kostyl.dev",
"username": "other_user",
"password": "Password1234"
}
response = await client.post("/auth/register", json=payload)
assert response.status_code == 400
assert response.json()["detail"] == "Email already registered"
Файл тестів завдань:
import pytest
from app.security import create_access_token
@pytest.fixture
def auth_headers(user_factory):
"""Генерує заголовок Authorization для тестів, що потребують авторизації."""
user = user_factory.create(email="auth_user@kostyl.dev")
token = create_access_token(data={"sub": user.email})
return {"Authorization": f"Bearer {token}"}
async def test_create_task_authorized(client, auth_headers):
"""Створення завдання авторизованим користувачем."""
payload = {
"title": "Build complete test suite",
"description": "Ensure everything is covered",
"priority": 5
}
response = await client.post("/tasks", json=payload, headers=auth_headers)
assert response.status_code == 201
data = response.json()
assert data["title"] == payload["title"]
assert data["priority"] == 5
assert "id" in data
async def test_create_task_unauthorized(client):
"""Спроба створити завдання без авторизаційного токену."""
payload = {"title": "Hack the planet"}
response = await client.post("/tasks", json=payload)
assert response.status_code == 401
async def test_get_only_user_tasks(client, user_factory, task_factory):
"""Користувач має бачити лише свої завдання."""
user_1 = user_factory.create(email="user1@kostyl.dev")
user_2 = user_factory.create(email="user2@kostyl.dev")
# Створюємо завдання для різних користувачів
task_factory.create_batch(2, owner=user_1)
task_factory.create(owner=user_2)
token = create_access_token(data={"sub": user_1.email})
headers = {"Authorization": f"Bearer {token}"}
response = await client.get("/tasks", headers=headers)
assert response.status_code == 200
tasks = response.json()
# Користувач 1 має бачити лише свої 2 завдання
assert len(tasks) == 2
Крок 11: Запуск та аналіз результатів
Тепер, коли всі файли створені, виконайте такі кроки для запуску тестів.
- Встановіть залежності проєкту (бажано у віртуальному оточенні):pip install$ pip install .$ pip install -e ".[dev]"
- Запустіть pytest для проходження всіх тестів та аналізу покриття коду:pytest$ pytest====================== test session starts ======================platform darwin -- Python 3.12.3, pytest-8.1.1collected 6 itemstests/test_auth.py ... [ 50%]tests/test_tasks.py ... [100%]---------- coverage: platform darwin, python 3.12.3-final-0 -----------Name Stmts Miss Cover Missing---------------------------------------------------app/__init__.py 0 0 100%app/database.py 18 3 83% 24-27app/main.py 56 3 95% 58-59, 70app/models.py 20 0 100%app/schemas.py 26 1 96% 19app/security.py 14 0 100%---------------------------------------------------TOTAL 134 7 95%======================= 6 passed in 0.98s =======================
Всі тести пройшли успішно, а загальний відсоток покриття коду (95%) значно перевищує необхідний поріг.
Домашнє завдання (TaskForge Practice)
Для успішного завершення цієї теми виконайте наступні завдання:
- Додаткові валідації: Додайте до схеми
TaskCreateперевірку того, щоdescriptionне може бути пустим рядком, і напишіть відповідний тест уtests/test_tasks.py. - Мокування відправки листів: Додайте в
app/main.pyвиклик сервісу надсилання повідомлень при успішній реєстрації користувача та покрийте цей флоу тестом, використовуючиmocker.patch. - CRUD-тести видалення: Допишіть інтеграційний тест для
DELETE /tasks/{id}, перевіривши, що користувач може видаляти лише власні завдання, а при спробі видалити чуже отримує403 Forbiddenабо404 Not Found.
Git Commit:
feat: add comprehensive test suite with pytest, fixtures and CI
Автентифікація та авторизація — JWT, OAuth2, RBAC
Глибоке занурення у безпеку FastAPI — хешування паролів, JSON Web Tokens, OAuth2 flow, RBAC на рівні проєкту та Security Headers. Порівняння з ASP.NET підходом для розробників, що мігрують на Python.
Async на повну — Background Tasks, WebSockets, SSE та Celery
Глибоке занурення в просунуті асинхронні можливості FastAPI. Навчимося виконувати фонові завдання, працювати з WebSockets та SSE, а також інтегрувати Celery для важких обчислень.