Python · Тестування з pytest · Експертний
Тестування FastAPI-застосунків
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
Шаблони тестування FastAPI на практиці
#Довідник: тестування FastAPI
#Вправи:
Тест: FastAPI GET-ендпоінт через 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`, що await-ить `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