Python · Тестирование с pytest · Экспертный
Тестирование FastAPI-приложений
Краткое введение в тему и пояснения перед упражнениями (упражнения ниже):
Паттерны тестирования FastAPI на практике
#Справочник по тестированию FastAPI
#Упражнения:
Проверить GET-эндпоинт FastAPI с TestClient
#У вас есть FastAPI-приложение с `GET /items/`, возвращающим `{'results': [...]}`. Напишите тест с `TestClient`, который: 1. Отправляет `GET /items/` 2. Проверяет статус 200 3. Проверяет что в JSON ответа есть ключ `results` 4. Проверяет что `results` является списком
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
items = [{'name': 'Widget', 'price': 9.99}]
@app.get('/items/')
def list_items():
return {'results': items}
def test_list_items():
pass
Решение
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
items = [{'name': 'Widget', 'price': 9.99}]
@app.get('/items/')
def list_items():
return {'results': items}
client = TestClient(app)
def test_list_items():
response = client.get('/items/')
assert response.status_code == 200
data = response.json()
assert 'results' in data
assert isinstance(data['results'], list)
Проверить POST-эндпоинт и содержимое ответа
#У вас есть `POST /items/`, принимающий `{'name': str, 'price': float}` как JSON-тело, создающий элемент и возвращающий его с HTTP 201. Напишите два теста: 1. **test_create_item** -- отправьте `{'name': 'Gadget', 'price': 24.99}` и проверьте: статус 201, `name == 'Gadget'`, `price == 24.99`. 2. **test_create_item_missing_field** -- отправьте без поля `price` и проверьте статус 422 (ошибка валидации Pydantic FastAPI).
from fastapi import FastAPI
from pydantic import BaseModel
from fastapi.testclient import TestClient
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.post('/items/', status_code=201)
def create_item(item: Item):
return item.model_dump()
client = TestClient(app)
def test_create_item():
pass
def test_create_item_missing_field():
pass
Решение
from fastapi import FastAPI
from pydantic import BaseModel
from fastapi.testclient import TestClient
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.post('/items/', status_code=201)
def create_item(item: Item):
return item.model_dump()
client = TestClient(app)
def test_create_item():
response = client.post('/items/', json={'name': 'Gadget', 'price': 24.99})
assert response.status_code == 201
data = response.json()
assert data['name'] == 'Gadget'
assert data['price'] == 24.99
def test_create_item_missing_field():
response = client.post('/items/', json={'name': 'Gadget'})
assert response.status_code == 422
Использовать dependency_overrides для изоляции состояния теста
#У вас есть FastAPI-приложение, где `GET /items/` и `POST /items/` разделяют список на уровне модуля через зависимость `get_store`. Тесты загрязняют друг друга, потому что все пишут в один и тот же список. Напишите pytest-фикстуру `isolated_client`, которая: 1. Создаёт свежий `[]` для каждого теста. 2. Устанавливает `app.dependency_overrides[get_store]` в функцию, возвращающую этот список. 3. Возвращает `TestClient` через yield. 4. Очищает `app.dependency_overrides` после теста. Напишите два теста с этой фикстурой, доказывающих изоляцию -- ни один тест не видит данные другого.
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from fastapi.testclient import TestClient
import pytest
app = FastAPI()
shared_store: list = []
def get_store():
return shared_store
class Item(BaseModel):
name: str
@app.get('/items/')
def list_items(store: list = Depends(get_store)):
return {'results': store}
@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_store)):
record = item.model_dump()
store.append(record)
return record
@pytest.fixture
def isolated_client():
pass
def test_create_item(isolated_client):
pass
def test_store_starts_empty(isolated_client):
pass
Решение
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from fastapi.testclient import TestClient
import pytest
app = FastAPI()
shared_store: list = []
def get_store():
return shared_store
class Item(BaseModel):
name: str
@app.get('/items/')
def list_items(store: list = Depends(get_store)):
return {'results': store}
@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_store)):
record = item.model_dump()
store.append(record)
return record
@pytest.fixture
def isolated_client():
store = []
app.dependency_overrides[get_store] = lambda: store
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_item(isolated_client):
isolated_client.post('/items/', json={'name': 'Widget'})
response = isolated_client.get('/items/')
assert len(response.json()['results']) == 1
assert response.json()['results'][0]['name'] == 'Widget'
def test_store_starts_empty(isolated_client):
response = isolated_client.get('/items/')
assert response.json()['results'] == []
Написать асинхронный тест с httpx.AsyncClient
#У вас есть FastAPI-приложение с `GET /items/`, возвращающим `{'results': [...]}`. Установите `asyncio_mode = auto` в `pytest.ini`. Напишите: 1. Асинхронную pytest-фикстуру `async_client`, создающую `httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test')` через `async with ... yield`. 2. Асинхронный тест `test_list_items_async`, ожидающий `GET /items/` и проверяющий статус 200 и наличие ключа `results` в ответе.
# pytest.ini:
# [pytest]
# asyncio_mode = auto
import httpx
import pytest
from fastapi import FastAPI
app = FastAPI()
items = [{'name': 'Widget'}]
@app.get('/items/')
def list_items():
return {'results': items}
@pytest.fixture
async def async_client():
pass
async def test_list_items_async(async_client):
pass
Решение
# pytest.ini:
# [pytest]
# asyncio_mode = auto
import httpx
import pytest
from fastapi import FastAPI
app = FastAPI()
items = [{'name': 'Widget'}]
@app.get('/items/')
def list_items():
return {'results': items}
@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_list_items_async(async_client):
response = await async_client.get('/items/')
assert response.status_code == 200
assert 'results' in response.json()
Проверить защищённый эндпоинт с фикстурой токена аутентификации
#У вас есть FastAPI-приложение с: - `POST /token` -- принимает OAuth2 данные формы, возвращает `{'access_token': ..., 'token_type': 'bearer'}` - `GET /me` -- требует валидный Bearer-токен, возвращает `{'username': ...}`; вызывает 401 без токена Напишите pytest-фикстуру `auth_client`, которая: 1. Создаёт `TestClient`. 2. Отправляет учётные данные на `/token` через `data={}` (form-encoded) и извлекает токен. 3. Устанавливает `Authorization: Bearer <token>` на клиенте для всех последующих запросов. 4. Возвращает аутентифицированный клиент через yield. Напишите: - `test_get_profile_authenticated` -- проверяет 200 и `{'username': 'alice'}`. - `test_get_profile_no_token` -- использует обычный `TestClient`, проверяет 401.
import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient
app = FastAPI()
users = {'alice': 'secret'}
tokens = {}
oauth2 = OAuth2PasswordBearer(tokenUrl='/token')
@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
if users.get(form.username) != form.password:
raise HTTPException(status_code=400, detail='Bad credentials')
token = f'tok-{form.username}'
tokens[token] = form.username
return {'access_token': token, 'token_type': 'bearer'}
def get_user(token: str = Depends(oauth2)):
name = tokens.get(token)
if not name:
raise HTTPException(status_code=401)
return name
@app.get('/me')
def me(username: str = Depends(get_user)):
return {'username': username}
@pytest.fixture
def auth_client():
pass
def test_get_profile_authenticated(auth_client):
pass
def test_get_profile_no_token():
pass
Решение
import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient
app = FastAPI()
users = {'alice': 'secret'}
tokens = {}
oauth2 = OAuth2PasswordBearer(tokenUrl='/token')
@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
if users.get(form.username) != form.password:
raise HTTPException(status_code=400, detail='Bad credentials')
token = f'tok-{form.username}'
tokens[token] = form.username
return {'access_token': token, 'token_type': 'bearer'}
def get_user(token: str = Depends(oauth2)):
name = tokens.get(token)
if not name:
raise HTTPException(status_code=401)
return name
@app.get('/me')
def me(username: str = Depends(get_user)):
return {'username': username}
@pytest.fixture
def auth_client():
client = TestClient(app)
resp = client.post(
'/token',
data={'username': 'alice', 'password': 'secret'},
)
token = resp.json()['access_token']
client.headers.update({'Authorization': f'Bearer {token}'})
yield client
tokens.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