Python · Тестування з pytest · Експертний

Тестування FastAPI-застосунків

5 завдань

Тестування FastAPI-застосунків: TestClient, AsyncClient, dependency_overrides

#
Система впровадження залежностей 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 на практиці

#
## Застосунок під тестом Усі приклади використовують мінімальний 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 на цей ендпоінт.

Довідник: тестування FastAPI

#
## `TestClient` vs `AsyncClient` | | `TestClient` | `httpx.AsyncClient` | |---|---|---| | Імпорт | `from fastapi.testclient import TestClient` | `import httpx` | | Тип тест-функції | `def test_...` (sync) | `async def test_...` | | Вимагає pytest-asyncio | Ні | Так (`asyncio_mode = auto`) | | Події життєвого циклу | Так (контекстний менеджер) | Так (контекстний менеджер) | | Найкраще для | Більшості тестів | Асинхронно-специфічної поведінки | ## `app.dependency_overrides` ```python # Встановити перевизначення app.dependency_overrides[original_dep] = replacement_dep # Прибрати після тесту -- ЗАВЖДИ app.dependency_overrides.clear() # Або видалити одне перевизначення del app.dependency_overrides[original_dep] ``` Для `yield`-залежностей заміна також повинна використовувати `yield`: ```python # Оригінал (yield-залежність) def get_db(): db = SessionLocal() try: yield db finally: db.close() # Перевизначення (yield-залежність) def fake_db(): yield {} # in-memory словник замість реальної сесії ``` Для не-yield залежностей підходить звичайний `return` або `lambda`: ```python app.dependency_overrides[get_store] = lambda: [] ``` ## Шаблон фікстури для ізольованого клієнта ```python @pytest.fixture def client(): store = [] app.dependency_overrides[get_store] = lambda: store with TestClient(app) as c: yield c app.dependency_overrides.clear() ``` ## Шаблони заголовка автентифікації ```python # Встановити один раз для всіх запитів цього клієнта client.headers.update({'Authorization': f'Bearer {token}'}) # Передати на конкретний запит client.get('/me', headers={'Authorization': f'Bearer {token}'}) ``` ## pytest.ini для асинхронних тестів FastAPI ```ini [pytest] asyncio_mode = auto asyncio_default_fixture_loop_scope = session ``` ## OAuth2: form-encoded даних vs JSON ```python # Правильно -- OAuth2PasswordRequestForm очікує form-encoded тіло client.post('/token', data={'username': 'alice', 'password': 'secret'}) # Неправильно -- повертає 422 client.post('/token', json={'username': 'alice', 'password': 'secret'}) ``` ## Поширені перевірки ```python assert response.status_code == 200 assert response.json() == {'key': 'value'} assert 'results' in response.json() assert response.status_code == 422 # помилка валідації Pydantic assert response.status_code == 401 # відсутній/недійсний токен assert response.status_code == 201 # створено ```
01

Тест: 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)
02

Тест 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
03

Ізоляція тестового стану через 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'] == []
04

Асинхронний тест через 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()
05

Тест захищеного ендпоінта через фікстуру з токеном автентифікації

#

У вас є 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