Система впровадження залежностей FastAPI робить його одним із найбільш тестованих веб-фреймворків Python. Замість того щоб патчити функції в місці виклику через `unittest.mock`, ви замінюєте цілі ланцюжки залежностей на рівні застосунку перед запуском тесту -- чисто, не торкаючись жодного виробничого коду.
## Два тестові клієнти
**`TestClient`** (синхронний, з `fastapi.testclient`):
```python
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_get_items():
response = client.get('/items/')
assert response.status_code == 200
```
`TestClient` побудований на httpx. Він запускає ваш ASGI-застосунок синхронно всередині тестового процесу -- реальний сервер не запускається. Це правильний вибір для більшості тестів: простий, швидкий і працює без `pytest-asyncio`. Тести -- це прості `def`-функції.
**`httpx.AsyncClient`** (асинхронний):
```python
# pytest.ini: asyncio_mode = auto
import httpx
import pytest
from myapp.main import app
@pytest.fixture
async def async_client():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
yield client
async def test_get_items(async_client):
response = await async_client.get('/items/')
assert response.status_code == 200
```
Використовуйте `AsyncClient`, коли код під тестом є суто асинхронним і вам потрібно перевірити асинхронну поведінку -- тестування конкурентних запитів, асинхронних генераторів або потокової передачі відповідей. Вимагає `pytest-asyncio` з `asyncio_mode = auto` у `pytest.ini`.
## `app.dependency_overrides` -- базовий шаблон тестування
У FastAPI маршрути оголошують свої залежності як параметри функцій:
```python
from fastapi import Depends
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get('/items/')
def list_items(db: Session = Depends(get_db)):
return db.query(Item).all()
```
У тестах замінюйте `get_db` на фіктивну функцію, що повертає in-memory сховище, -- не торкаючись жодного виробничого коду:
```python
def fake_get_db():
yield {'items': []}
app.dependency_overrides[get_db] = fake_get_db
```
Перевизначення застосовується до кожного запиту через застосунок, поки воно встановлено. Завжди прибирайте після завершення -- інакше перевизначення зберігається між тест-функціями і забруднює наступні тести:
```python
app.dependency_overrides.clear()
```
Стандартний шаблон загортає налаштування та прибирання у pytest-фікстуру з `yield`:
```python
@pytest.fixture
def client_with_fake_db():
store = []
def override():
yield store
app.dependency_overrides[get_db] = override
yield TestClient(app), store
app.dependency_overrides.clear() # виконується завжди, навіть при збої тесту
```
## Коли який клієнт використовувати
| Сценарій | Клієнт |
|---|---|
| Тестування запиту/відповіді (статус, JSON-тіло) | `TestClient` |
| Тестування асинхронної поведінки або конкурентних запитів | `AsyncClient` |
| Потокова передача відповідей, WebSocket | `AsyncClient` |
| Простота і швидкість | `TestClient` |
## Події життєвого циклу в тестах
FastAPI-застосунки часто мають події старту/зупинки (ініціалізація пулу з'єднань, завантаження ML-моделей). `TestClient` запускає їх при використанні як контекстний менеджер:
```python
with TestClient(app) as client:
# старт виконано
response = client.get('/items/')
# зупинку виконано
```
Для тестів, що перевіряють стан, залежний від старту, завжди використовуйте форму контекстного менеджера.
## `yield` vs `return` у перевизначеннях залежностей
Коли оригінальна залежність використовує `yield` (поширено для ресурсів, що потребують завершення -- сесії БД, відкриті файли, мережеві з'єднання), ваша замінна залежність теж **повинна використовувати `yield`**. Звичайний `return` у заміні мовчки пропускає завершення:
```python
# Оригінал -- yield-залежність із завершенням
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close() # ← це завершення виконується після запиту
# НЕПРАВИЛЬНО -- override використовує return; db.cleanup() ніколи не виконається
def fake_db_wrong():
return FakeSession() # FastAPI отримує значення; фаза завершення відсутня
# ПРАВИЛЬНО -- override використовує yield; завершення виконується після повернення обробника маршруту
def fake_db_correct():
db = FakeSession()
try:
yield db
finally:
db.cleanup() # ← гарантоване завершення
```
FastAPI вирішує перевизначення як генератор, якщо воно містить `yield`. Якщо це звичайна функція (без `yield`), FastAPI викликає її, бере значення, що повертається, і пропускає будь-яку логіку завершення. Це не помилка -- FastAPI не попереджає вас -- тому баг є непомітним.
`lambda`-перевизначення підходить лише для залежностей без завершення:
```python
# OK -- get_store просто повертає список, завершення не потрібно
app.dependency_overrides[get_store] = lambda: []
# НЕПРАВИЛЬНО -- get_db має завершення; lambda мовчки пропускає його
app.dependency_overrides[get_db] = lambda: FakeSession() # db.close() ніколи не виконається
```
## Перевизначення сесії бази даних SQLAlchemy
> **Примітка щодо версії SQLAlchemy:** Шаблон нижче використовує `bind=` у `sessionmaker` і `Session` -- ці ключові аргументи були застаріли в SQLAlchemy 1.4 і **видалені в SQLAlchemy 2.0**. Для SQLAlchemy 2.x замініть `sessionmaker(bind=engine)` на `sessionmaker(engine)` і `TestingSession(bind=connection)` на шаблон `join_transaction_mode` (дивіться документацію SQLAlchemy 2.x для тестування). Підхід з перевизначенням залежностей і концепція ізоляції тестів однакові у всіх версіях.
Перевизначення залежності сесії бази даних -- найпоширеніший шаблон `dependency_overrides` у реальних FastAPI-проектах. Канонічне налаштування тестів створює in-memory SQLite-базу даних для тестового сеансу, загортає кожен тест у транзакцію з відкатом і впроваджує одну й ту саму сесію як у тест, так і в застосунок:
```python
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.database import get_db, Base
engine = create_engine('sqlite://', connect_args={'check_same_thread': False})
TestingSession = sessionmaker(bind=engine)
@pytest.fixture(scope='session', autouse=True)
def create_tables():
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session():
connection = engine.connect()
transaction = connection.begin()
session = TestingSession(bind=connection)
yield session
session.close()
transaction.rollback() # ← кожен INSERT/UPDATE/DELETE з тесту скасовується
connection.close()
@pytest.fixture
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
```
Ключові рішення в цьому шаблоні:
- `scope='session'` для `create_tables` -- виконати створення схеми один раз для всього тест-рану, а не на кожен тест.
- `db_session` на рівні тесту загортає кожен тест у транзакцію і відкочує її після завершення. Кожен тест починається з чистою схемою без видалення і повторного створення таблиць.
- Фікстура `client` впроваджує ту саму сесію, яку тримає тест, тому ви можете читати записи застосунку безпосередньо з `db_session` без другого запиту через окреме з'єднання.
## BackgroundTasks: поведінка `TestClient` vs `AsyncClient`
`BackgroundTasks` FastAPI запускає функції після повернення відповіді клієнту. Поведінка в тестах відрізняється між двома клієнтами:
```python
from fastapi import BackgroundTasks
results = []
def record(value: str):
results.append(value)
@app.post('/jobs/')
def create_job(bt: BackgroundTasks):
bt.add_task(record, 'done')
return {'status': 'queued'}
```
**З `TestClient`** фонові задачі виконуються **синхронно до** того, як `TestClient` повертає відповідь. До того моменту, коли виконується ваша перевірка, задача вже завершена:
```python
def test_job_with_test_client():
results.clear()
response = client.post('/jobs/')
assert response.status_code == 200
assert 'done' in results # задача виконалась синхронно до повернення TestClient
```
**З `AsyncClient` і `ASGITransport`** фонові задачі також завершуються **до** того, як повертається `await async_client.post(...)`. Starlette викликає фонові задачі як частину циклу життя ASGI-відповіді, а `ASGITransport` керує цим циклом синхронно всередині тестової корутини -- окрема asyncio-задача, до якої можна поступитися, не планується:
```python
async def test_job_with_async_client(async_client):
results.clear()
response = await async_client.post('/jobs/')
assert response.status_code == 200
assert 'done' in results # задача вже виконалась до повернення відповіді
```
Обидва тестові клієнти поводяться однаково: фонові задачі завершуються до повернення об'єкта відповіді. Це відрізняється від продакшену -- реальний сервер спочатку надсилає HTTP-відповідь, а потім запускає задачі, тому клієнт отримує відповідь до завершення задач. У тестах з обома клієнтами ви можете одразу перевіряти результати задач після запиту.
## Тестування кастомних обробників винятків
За замовчуванням `TestClient` повторно кидає будь-який виняток на стороні сервера в тестовому процесі. Якщо маршрут кидає `ValueError`, тест бачить `ValueError`, а не відповідь 500 -- ви не можете перевірити HTTP-статус.
Коли у вас є кастомний обробник винятків і ви хочете перевірити, що він повертає правильний статус і тіло, вимкніть повторне кидання за допомогою `raise_server_exceptions=False`:
```python
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, code: int, message: str):
self.code = code
self.message = message
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.code, content={'error': exc.message})
@app.get('/boom')
def boom():
raise AppError(409, 'conflict')
```
```python
def test_custom_error_handler():
error_client = TestClient(app, raise_server_exceptions=False)
response = error_client.get('/boom')
assert response.status_code == 409
assert response.json() == {'error': 'conflict'}
```
Без `raise_server_exceptions=False` `TestClient` поширив би `AppError` у тест і рядки з перевірками ніколи б не виконались. Тримайте цей прапор обмеженим лише тестами, що конкретно тестують обробку помилок -- для всіх інших тестів вам потрібно, щоб винятки поширювались, аби неочікувані серверні помилки спричиняли негайні збої тестів.
## Завантаження файлів
Використовуйте параметр `files` для надсилання multipart-завантажень файлів через `TestClient`. Значення -- це словник, що відображає назву поля форми на кортеж `(filename, file-like-object, content-type)`:
```python
from fastapi import UploadFile, File
@app.post('/upload/')
async def upload_file(file: UploadFile = File(...)):
content = await file.read()
return {'filename': file.filename, 'size': len(content)}
```
```python
import io
def test_file_upload():
data = b'hello world'
response = client.post(
'/upload/',
files={'file': ('hello.txt', io.BytesIO(data), 'text/plain')},
)
assert response.status_code == 200
assert response.json() == {'filename': 'hello.txt', 'size': 11}
```
Формат кортежу -- `(filename, file-like-object, content-type)`. Частина `content-type` є необов'язковою -- якщо пропущена, httpx визначає її за розширенням файлу. Для бінарних або невідомих файлів використовуйте `'application/octet-stream'`.
Для multipart-форм, що поєднують звичайні поля з файлами, комбінуйте `data` і `files`:
```python
response = client.post(
'/upload/',
data={'description': 'profile picture'},
files={'avatar': ('avatar.png', io.BytesIO(png_bytes), 'image/png')},
)
```
## Застосунок під тестом
Усі приклади використовують мінімальний FastAPI-застосунок з елементами, залежністю та токен-автентифікацією:
```python
# myapp/main.py
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
app = FastAPI()
items_db: list = []
users_db = {'alice': 'secret'}
tokens_db: dict = {}
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/token')
def get_items_store():
return items_db
class Item(BaseModel):
name: str
price: float
@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
if users_db.get(form.username) != form.password:
raise HTTPException(status_code=400, detail='Bad credentials')
token = f'tok-{form.username}'
tokens_db[token] = form.username
return {'access_token': token, 'token_type': 'bearer'}
def get_current_user(token: str = Depends(oauth2_scheme)):
name = tokens_db.get(token)
if not name:
raise HTTPException(status_code=401)
return name
@app.get('/items/')
def list_items(store: list = Depends(get_items_store)):
return {'results': store}
@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_items_store)):
record = item.model_dump()
store.append(record)
return record
@app.get('/me')
def get_profile(username: str = Depends(get_current_user)):
return {'username': username}
```
## Синхронні тести з `TestClient`
```python
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_list_items_empty():
response = client.get('/items/')
assert response.status_code == 200
assert response.json() == {'results': []}
def test_create_item():
response = client.post('/items/', json={'name': 'Widget', 'price': 9.99})
assert response.status_code == 201
data = response.json()
assert data['name'] == 'Widget'
assert data['price'] == 9.99
```
Проблема: `items_db` -- це список на рівні модуля, тому ці два тести поділяють стан. Якщо `test_create_item` виконується першим, `test_list_items_empty` провалиться, оскільки список вже містить елемент.
## Ізоляція тестів через `dependency_overrides`
Замінюємо `get_items_store` на функцію, що повертає свіжий список для кожного тесту:
```python
import pytest
from fastapi.testclient import TestClient
from myapp.main import app, get_items_store
@pytest.fixture
def isolated_client():
store = []
def override():
return store
app.dependency_overrides[get_items_store] = override
yield TestClient(app), store
app.dependency_overrides.clear()
def test_create_and_list(isolated_client):
client, store = isolated_client
client.post('/items/', json={'name': 'Widget', 'price': 9.99})
response = client.get('/items/')
assert len(response.json()['results']) == 1
assert response.json()['results'][0]['name'] == 'Widget'
def test_store_is_isolated(isolated_client):
client, store = isolated_client
assert store == [] # елемент з попереднього тесту відкочено
```
## Асинхронні тести з `AsyncClient`
```python
# pytest.ini: asyncio_mode = auto
import httpx
import pytest
from myapp.main import app, get_items_store
@pytest.fixture
async def async_client():
store = []
app.dependency_overrides[get_items_store] = lambda: store
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
yield client
app.dependency_overrides.clear()
async def test_list_items_async(async_client):
response = await async_client.get('/items/')
assert response.status_code == 200
assert response.json()['results'] == []
```
## Тестування захищеного ендпоінта через фікстуру автентифікації
```python
@pytest.fixture
def auth_client():
app.dependency_overrides[get_items_store] = lambda: []
with TestClient(app) as client:
resp = client.post(
'/token',
data={'username': 'alice', 'password': 'secret'},
)
token = resp.json()['access_token']
client.headers.update({'Authorization': f'Bearer {token}'})
yield client
app.dependency_overrides.clear()
def test_get_profile_authenticated(auth_client):
response = auth_client.get('/me')
assert response.status_code == 200
assert response.json() == {'username': 'alice'}
def test_get_profile_no_token():
client = TestClient(app)
response = client.get('/me')
assert response.status_code == 401
```
Ендпоінт `/token` використовує `OAuth2PasswordRequestForm`, який читає тіло у форматі form-encoded -- використовуйте `data={}`, а не `json={}`. FastAPI повертає 422, якщо ви надсилаєте JSON на цей ендпоінт.