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`, когда тестируемый код по своей природе асинхронен и нужно проверить async-поведение -- тестирование конкурентных запросов, асинхронных генераторов или потоковых ответов. Требует `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` | | Тестирование async-поведения или конкурентных запросов | `AsyncClient` | | Потоковые ответы, WebSocket | `AsyncClient` | | Простота и скорость | `TestClient` | ## События жизненного цикла в тестах FastAPI-приложения часто имеют события запуска/завершения (инициализация пула соединений, загрузка ML-моделей). `TestClient` запускает их при использовании как контекстного менеджера: ```python with TestClient(app) as client: # startup уже выполнен response = client.get('/items/') # shutdown выполнен ``` Для тестов, проверяющих состояние, зависящее от запуска, всегда используйте форму контекстного менеджера. ## `yield` vs `return` в переопределениях зависимостей Когда исходная зависимость использует `yield` (распространено для ресурсов, требующих завершения -- сессий БД, открытых файлов, сетевых соединений), ваш заменяющий переопределитель тоже **должен использовать `yield`**. Обычный `return` в замене молча пропускает завершение: ```python # Оригинал -- зависимость с yield и завершением def get_db(): db = SessionLocal() try: yield db finally: db.close() # <- это завершение выполняется после запроса # НЕВЕРНО -- переопределение использует return; db.cleanup() никогда не выполнится def fake_db_wrong(): return FakeSession() # FastAPI получает значение; фаза завершения отсутствует # ВЕРНО -- переопределение использует 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)` и используйте паттерн `join_transaction_mode` (см. документацию по тестированию SQLAlchemy 2.x). Подход с `dependency_overrides` и концепция изоляции тестов идентичны во всех версиях. Переопределение зависимости сессии базы данных -- наиболее распространённый паттерн `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` управляет этим жизненным циклом синхронно внутри корутины теста: ```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_...` (синхронный) | `async def test_...` | | Требует pytest-asyncio | Нет | Да (`asyncio_mode = auto`) | | События жизненного цикла | Да (контекстный менеджер) | Да (контекстный менеджер) | | Лучше для | Большинства тестов | Async-специфичного поведения | ## `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: данные формы 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

Проверить 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)
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`, ожидающий `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