FastAPI

Тестування FastAPI — pytest, httpx, fixtures

Глибоке занурення у тестування FastAPI застосунків. Перехід від концепцій xUnit до pytest, налаштування середовища, unit-тестування Pydantic-схем та параметризовані тести.

Тестування FastAPI — pytest, httpx, fixtures

Якісний програмний продукт відрізняється від «просто працюючого прототипу» однією важливою характеристикою — передбачуваністю. У світі корпоративної розробки ви не можете дозволити собі сподіватися, що чергова зміна в коді не зламає авторизацію чи логіку підрахунку кошика. Єдиний спосіб гарантувати стабільність системи при постійному рефакторингу та додаванні нових фіч — це автоматизоване тестування.

Розробники, які приходять у Python-екосистему з платформи .NET, уже мають міцну базу знань про життєвий цикл тестів, мокування та інтеграційні тести. Однак інструментарій Python має свою специфіку. Цей матеріал покликаний не лише навчити вас писати тести для FastAPI з нуля, але й провести паралелі з концепціями .NET (xUnit, NUnit, WebApplicationFactory), щоб полегшити перехід та сформувати глибоке розуміння процесів.


1. Філософія тестування у FastAPI та піраміда тестування

Перш ніж писати перший рядок тестового коду, згадаймо класичну концепцію — піраміду тестування. Вона визначає ідеальне співвідношення різних типів тестів у проекті:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #3b82f6
skinparam RectangleBackgroundColor #f8fafc
skinparam RectangleBorderColor #cbd5e1

rectangle "E2E / System Tests\n(Повне тестування інтерфейсу та API)" as e2e #fee2e2
rectangle "Integration / API Tests\n(Тестування ендпоінтів, БД, інтеграцій)" as integration #ffedd5
rectangle "Unit Tests\n(Чисті функції, Pydantic схеми, бізнес-логіка)" as unit #e0f2fe

e2e -[hidden]down-> integration
integration -[hidden]down-> unit

note right of e2e
  Найдорожчі в підтримці,
  повільні, запускають усю систему.
  (Selenium, Playwright)
end note

note right of integration
  Перевіряють взаємодію компонентів,
  роботу з БД та HTTP.
  (FastAPI TestClient + pytest fixtures)
end note

note right of unit
  Найшвидші, ізольовані,
  тестують логіку в пам'яті.
  (pytest + Mock)
end note

@enduml

У контексті FastAPI-застосунків кожен рівень піраміди має своє практичне втілення:

  1. Unit-тести: Тестують найменші ізольовані елементи коду. У FastAPI це зазвичай Pydantic-схеми (чи правильно валідуються дані), допоміжні чисті функції (хешування паролів, форматування дат) та окремі сервіси бізнес-логіки без доступу до зовнішніх ресурсів.
  2. Integration (інтеграційні) тести: Перевіряють, як компоненти системи працюють разом. Це серце тестування FastAPI. Тут ми використовуємо TestClient (на базі httpx) для виконання віртуальних HTTP-запитів до наших ендпоінтів, використовуємо тестову базу даних PostgreSQL і перевіряємо middleware та обробку помилок.
  3. E2E-тести: Перевіряють систему від початку до кінця з точки зору кінцевого користувача, зазвичай імітуючи дії у веб-браузері.

Порівняльний аналіз концепцій: C# ↔ Python

Для розробників із досвідом в ASP.NET Core інструментарій Python може здатися незвичним через динамічну природу мови та іншу філософію тестових фреймворків. Порівняймо ключові концепції:

Концепція у .NET (ASP.NET Core)Еквівалент у Python (FastAPI / pytest)Опис різниці
xUnit / NUnitpytestpytest є функціональним фреймворком. Замість класів-фікстур та атрибутів використовуються прості функції та декоратори.
[Fact] / [Theory]test_* функції / @pytest.mark.parametrizeВ pytest будь-яка функція, яка починається з test_, є тестом. Параметризація робиться через декоратор.
IClassFixture<T> / Setup-Teardownpytest Fixtures (yield)Замість життєвого циклу класів та IDisposable, pytest використовує потужну систему DI-фікстур з ключовим словом yield.
WebApplicationFactory + HttpClientTestClient (або AsyncClient з httpx)FastAPI надає вбудований клієнт, який запускає ASGI-застосунок в пам'яті без підняття реального мережевого порту.
ConfigureTestServicesapp.dependency_overridesЗамість перевизначення сервісів у IoC-контейнері DI через перевизначення провайдерів, FastAPI дозволяє просто підмінити будь-яку залежність через словник перевизначень.
Moq / NSubstituteunittest.mock / pytest-mockЗавдяки динамічній природі Python, мокування не потребує інтерфейсів. Будь-який об'єкт або метод можна "запатчити" під час виконання.
BogusFaker / Factory BoyБібліотеки для генерації реалістичних тестових даних (імена, пошти, дати).

2. Знайомство з pytest та налаштування середовища

pytest — це стандарт де-факто для тестування в Python. На відміну від стандартної бібліотеки unittest (яка побудована на базі застарілої концепції JUnit з Java), pytest використовує максимально лаконічний, "пітонічний" підхід. Вам не потрібно створювати класи та успадковувати їх від базових тестових класів — достатньо написати звичайну функцію та використати стандартне ключове слово assert.

Встановлення залежностей

Для організації повноцінного тестового середовища нам знадобиться сам фреймворк pytest, клієнт httpx для запитів та утиліти для асинхронного тестування.

pip install 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:

app/schemas/task.py
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

Ця схема має кілька правил валідації:

  1. title має бути від 3 до 100 символів.
  2. priority обмежений діапазоном від 1 до 5 включно.
  3. due_date не може бути в минулому (custom field validator).
  4. Опис не повинен містити в собі назву (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):

tests/unit/test_task_schemas.py
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
# Продовження файлу 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 приймає два основних аргументи:

  1. Рядок з іменами параметрів, які будуть передані у тестову функцію (наприклад, "invalid_priority").
  2. Ітерований об'єкт (список, кортеж), де кожен елемент є набором аргументів для одного запуску тесту.

Ми можемо передавати кілька параметрів одночасно. Давайте протестуємо логіку нашого кастомного валідатора, який забороняє вибирати дедлайн у минулому, а також перевіримо поведінку з коректними датами:

tests/unit/test_task_schemas.py
# Продовження файлу 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"]
Порівняння синтаксису параметризаціїЗверніть увагу, наскільки чисто виглядає параметризований тест у pytest порівняно з C#:
[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);
    // Логіка тесту...
}
У Python ми можемо передавати об'єкти складних типів (наприклад, динамічно обчислені дати date.today() + timedelta(...)) безпосередньо у параметри декоратора. В C# атрибути [InlineData] приймають лише константні вирази, що змушує розробників використовувати складніші конструкції на кшталт [MemberData] або [ClassData].

4. Фікстури (Fixtures) у pytest: Основа Dependency Injection

У традиційних об'єктно-орієнтованих тестових фреймворках (на кшталт xUnit чи MSTest) для підготовки тестового середовища використовуються методи SetUp та TearDown (або конструктор класу та метод Dispose()). Усі тести в межах одного класу ділять ці методи.

Цей підхід має суттєвий недолік — жорстку залежність. Якщо частина тестів у класі потребує підключення до БД, а частина — ні, вам доведеться або створювати підключення для всіх тестів, або розносити їх по різних файлах, що штучно ускладнює структуру проекту.

pytest пропонує принципово іншу модель — фікстури (fixtures). Це функції, які постачають ресурси, дані або сервіси для ваших тестів через механізм впровадження залежностей (Dependency Injection).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #3b82f6

package "Тестове середовище pytest" {
    component "conftest.py\n(Глобальні фікстури)" as conftest #f8fafc
    component "test_users.py\n(Файл тестів)" as test_file #f8fafc
}

database "Тестова База Даних" as db #e2e8f0

conftest -down-> db : 1. Створює схему / з'єднання
conftest -right-> test_file : 2. Впроваджує fixture `db_session`
test_file -> test_file : 3. Виконує тест з `db_session`
conftest <-down- db : 4. Робить Rollback транзакції (teardown)
@enduml

Створення базової фікстури

Для створення фікстури використовується декоратор @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:

scope='function'
за замовчуванням
Фікстура викликається для кожного тестового методу окремо. Це гарантує повну ізоляцію: зміни, внесені в об'єкт в одному тесті, не вплинуть на інший тест.
scope='class'
Фікстура викликається один раз на клас тестів (якщо тести об'єднані в клас class TestSomething).
scope='module'
Фікстура викликається один раз на Python-файл (модуль). Корисно для групування тестів, що працюють з одним файлом конфігурації.
scope='package'
Фікстура створюється один раз на пакет (папку).
scope='session'
Фікстура створюється один раз на весь час запуску тестів (тестову сесію). Ідеально підходить для запуску тестових контейнерів (testcontainers) або створення пулу підключень до бази даних.

Життєвий цикл: 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.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #3b82f6

participant "pytest (Test)" as test #f8fafc
participant "TestClient (httpx)" as client #e0f2fe
participant "ASGI Application\n(FastAPI app)" as app #e8f5e9
database "Database / External Services" as db #f3e5f5

test -> client: client.get("/api/tasks")
activate client
client -> app: Виклик ASGI-інтерфейсу (в пам'яті)
activate app
app -> db: Запит даних
db --> app: Результат
app --> client: HTTP-like Response (status, body)
deactivate app
client --> test: httpx.Response
deactivate client
@enduml

Під капотом 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:

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:

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:

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
# Продовження файлу 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 і надає такі корисні властивості та методи:

status_code
int
HTTP-код відповіді (наприклад, 200, 201, 400, 401, 404, 500).
json()
method
Парсить тіло відповіді як JSON і повертає стандартний Python dict або list. Якщо тіло не є валідним JSON, виникає виключення json.JSONDecodeError.
headers
dict-like
Словник із заголовками відповіді (наприклад, response.headers["content-type"]).
text
str
Повертає сире тіло відповіді у вигляді рядка (корисно для налагодження, якщо сервер повернув HTML-сторінку помилки замість JSON).

6. Робота з тестовою базою даних (Database Fixtures) та транзакційні відкати

Тестування ендпоінтів, які працюють з базою даних, є однією з найскладніших задач. Тут постає серйозний виклик: як зробити так, щоб тести не залежали один від одного та виконувалися швидко?

Якщо перший тест створює завдання з назвою "Task 1", а другий тест намагається отримати список усіх завдань і очікує там порожній список — другий тест впаде через залишки даних від першого.

Стратегії очищення бази даних

В екосистемі .NET розробники використовують кілька підходів:

  • InMemoryDatabase (швидко, але не підтримує специфічні фічі на кшталт сирих SQL-запитів чи JSONB-колон PostgreSQL).
  • Respawn — бібліотека, яка очищає таблиці (виконує TRUNCATE) перед або після кожного тесту.

У Python найбільш елегантним, надійним та швидким підходом є транзакційний відкат (Transaction Rollback).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #3b82f6

participant "pytest (Test)" as test #f8fafc
participant "SQLAlchemy Session" as session #e0f2fe
database "PostgreSQL" as db #f3e5f5

== Setup ==
test -> session: Почати транзакцію (transaction.begin)
activate session
session -> db: BEGIN TRANSACTION

== Test Execution ==
test -> session: session.add(Task)
session -> db: INSERT INTO tasks ...
test -> session: session.commit() (імітація)
note right of session
  SQLAlchemy перехоплює комміт
  і не відправляє його в БД реально,
  або використовує SAVEPOINT
end note

== Teardown ==
test -> session: Відкат (transaction.rollback)
session -> db: ROLLBACK TRANSACTION
deactivate session
note right of db
  Всі зміни скасовано!
  База даних повертається
  до початкового стану.
end note
@enduml

Суть підходу:

  1. Перед стартом тесту ми відкриваємо транзакцію в базі даних.
  2. Під час тесту всі операції запису (INSERT, UPDATE, DELETE) виконуються в межах цієї транзакції.
  3. Після завершення тесту (неважливо, успішного чи ні) ми робимо ROLLBACK цієї транзакції.
  4. Дані ніколи фізично не зберігаються в БД назавжди, і кожен тест бачить абсолютно чисту схему.

Реалізація фікстур бази даних у conftest.py

Давайте реалізуємо цей механізм за допомогою SQLAlchemy та FastAPI. Ми будемо використовувати асинхронний драйвер.

tests/conftest.py
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-контейнері:

ASP.NET Core (C#)
// Перевизначення у 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
# Продовження 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:

tests/integration/test_tasks.py
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 для генерації заглушок у рантаймі:

Moq (C#)
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:

app/services/email.py
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 додатку викликає цей сервіс:

app/main.py
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:

tests/integration/test_assign.py
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"
    )
Типова помилка: не той шлях для patchФункція 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.

Faker
library
Генерує випадкові, але реалістичні дані: імена, адреси, номери телефонів, тексти, паролі, дати тощо.
Factory Boy
library
Дозволяє створювати декларативні фабрики для моделей даних, що спрощує генерацію складних взаємопов'язаних об'єктів (наприклад, User -> Projects -> Tasks). Вона має інтеграцію з SQLAlchemy.

Налаштування фабрик

Створимо фабрику для генерації завдань (моделі SQLAlchemy Task):

tests/factories.py
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
# Додаємо в tests/conftest.py
from tests.factories import TaskFactory

@pytest.fixture
def task_factory(db_session):
    """Фікстура для створення завдань у тестовій БД."""
    # Зв'язуємо фабрику з поточною тестовою сесією
    TaskFactory._meta.sqlalchemy_session = db_session
    return TaskFactory

Використання фабрики в тестах

Завдяки фабриці, створення тестових записів у базі даних стає неймовірно простим:

tests/integration/test_tasks_query.py
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
# Продовження файлу 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:

pytest --cov
$ pytest --cov=app tests/
plugins: asyncio-0.23.6, cov-5.0.0, mock-3.14.0
collected 12 items
tests/unit/test_task_schemas.py ... [ 25%]
tests/integration/test_health.py .. [ 41%]
tests/integration/test_tasks.py .... [ 75%]
tests/integration/test_tasks_query.py ... [100%]
---------- coverage: platform darwin, python 3.12.3-final-0 -----------
Name Stmts Miss Cover
--------------------------------------------
app/__init__.py 0 0 100%
app/main.py 18 2 88%
app/models/task.py 10 0 100%
app/schemas/task.py 22 1 95%
app/services/email.py 7 3 57%
--------------------------------------------
TOTAL 57 6 89%
Required test coverage of 80.0% reached. Total coverage: 89.47%
======= 12 passed in 1.42s =======

Звіт містить такі колонки:

  • Stmts: Загальна кількість інструкцій (рядків коду) у файлі.
  • Miss: Кількість рядків коду, які жодного разу не виконувалися під час запуску тестів.
  • Cover: Процентне відношення покриття.

Детальні звіти та поріг покриття

Ви можете згенерувати інтерактивний HTML-звіт, який дозволить побачити конкретні рядки коду, що не були покриті:

pytest --cov=app --cov-report=html tests/

Це створить папку htmlcov/ з веб-сайтом звіту. Відкрийте htmlcov/index.html у браузері.

Також корисно налаштувати автоматичне падіння тестів, якщо загальне покриття є нижчим за певний поріг (наприклад, 80%). Це спонукатиме команду писати тести для нових фіч:

pyproject.toml
# Додаємо в конфігурацію 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 буде виконувати три етапи:

  1. Linting — перевірка стилю коду за допомогою надшвидкого інструменту ruff.
  2. Type Checking — статична перевірка типів через mypy.
  3. Testing — запуск тестів з аналізом покриття та підняттям тимчасової бази даних PostgreSQL.
.github/workflows/ci.yml
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:

pyproject.toml
[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).

app/database.py
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 (завдання).

app/models.py
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.

app/schemas.py
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)

Проста логіка для хешування паролів та формування токенів:

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).

app/main.py
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 в нашому додатку:

tests/conftest.py
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)

Ми налаштовуємо фабрики для автоматичного наповнення БД користувачами та завданнями:

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
# Додайте в кінець 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)

Напишемо перевірки для наших ендпоінтів.

Файл тестів автентифікації:

tests/test_auth.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"

Файл тестів завдань:

tests/test_tasks.py
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: Запуск та аналіз результатів

Тепер, коли всі файли створені, виконайте такі кроки для запуску тестів.

  1. Встановіть залежності проєкту (бажано у віртуальному оточенні):
    pip install
    $ pip install .
    $ pip install -e ".[dev]"
  2. Запустіть pytest для проходження всіх тестів та аналізу покриття коду:
    pytest
    $ pytest
    ====================== test session starts ======================
    platform darwin -- Python 3.12.3, pytest-8.1.1
    collected 6 items
    tests/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-27
    app/main.py 56 3 95% 58-59, 70
    app/models.py 20 0 100%
    app/schemas.py 26 1 96% 19
    app/security.py 14 0 100%
    ---------------------------------------------------
    TOTAL 134 7 95%
    ======================= 6 passed in 0.98s =======================

Всі тести пройшли успішно, а загальний відсоток покриття коду (95%) значно перевищує необхідний поріг.


Домашнє завдання (TaskForge Practice)

Для успішного завершення цієї теми виконайте наступні завдання:

  1. Додаткові валідації: Додайте до схеми TaskCreate перевірку того, що description не може бути пустим рядком, і напишіть відповідний тест у tests/test_tasks.py.
  2. Мокування відправки листів: Додайте в app/main.py виклик сервісу надсилання повідомлень при успішній реєстрації користувача та покрийте цей флоу тестом, використовуючи mocker.patch.
  3. CRUD-тести видалення: Допишіть інтеграційний тест для DELETE /tasks/{id}, перевіривши, що користувач може видаляти лише власні завдання, а при спробі видалити чуже отримує 403 Forbidden або 404 Not Found.

Git Commit: feat: add comprehensive test suite with pytest, fixtures and CI

Copyright © 2026