Урок курса
Fixtures, временная база и dependency override
FastAPI для начинающих: API с базой данных и тестамиВ прошлом уроке TestClient отправлял запросы к читающему приложению без базы данных — это позволяло не думать об изоляции. Теперь цель другая: протестировать сохраняющий API с SQLite, и каждый тест должен работать с чистой базой, не видеть данные соседнего теста и не трогать рабочий файл базы.
Fixture client: подготовка и завершение вокруг одного теста
В pytest fixture — это функция, которую тест просит подготовить ресурс. Запрос выглядит максимально просто: достаточно добавить параметр с именем fixture в сигнатуру теста.
def test_empty_rooms(client):
...
def test_create_room(client):
...
Параметр client — это и есть запрос. pytest находит fixture с именем client, запускает её и передаёт результат в тест.
Функция fixture помечается декоратором @pytest.fixture. Внутри — структура с yield: код до yield готовит ресурс, yield передаёт его тесту, код после yield завершает работу ресурса.
Порядок действий fixture (это схема, не Python-код):
Подготовить временную базу и клиент
Передать клиент тесту через yield
После теста освободить ресурсы в finally
По умолчанию применяется scope="function": каждый тест получает отдельный запуск fixture от начала до конца. Если два теста запрашивают client, fixture запустится дважды — для первого теста, потом для второго. Это принципиально: данные, созданные в первом тесте, не попадают во второй, потому что второй тест получает свою базу от своего запуска fixture.
Завершение гарантируется блоком try/finally: если тест падает на assert, код в finally всё равно выполняется. Это важно для освобождения файлов и соединений. Гарантия действует при обычных ошибках и падениях assert; принудительное завершение процесса этой гарантии не даёт.
Один момент, который легко упустить: новый TestClient сам по себе не обнуляет базу данных. Он вызывает приложение внутри процесса и не подменяет используемое им хранилище. Если бы все тесты работали с одним и тем же файлом базы, запись из первого теста была бы видна во втором — независимо от того, создаётся ли новый TestClient. Изоляцию обеспечивает именно fixture, которая каждый раз создаёт новый временный файл.
Отдельный SQLite-engine и отсутствие SQL при импорте
Чтобы тест работал с отдельной базой, нужно решить одну проблему заранее: в типичном проекте create_all вызывается при импорте модуля, и pytest, собирая тесты, уже создаёт рабочий файл базы. Нужно убрать этот побочный эффект.
Подготовка папки
Создайте папку testing_demo и скопируйте в неё все Python-файлы сохраняющего API из урока 26: models.py, database.py, schemas.py, booking_schemas.py, booking_utils.py, main.py. Файлы *.db не копируйте. Затем замените database.py полностью — файл ниже отличается от оригинала в двух ключевых местах.
# database.py
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from models import Base
DATABASE_URL = "sqlite:///./roomly_intervals.db"
def create_db_engine(url):
configured_engine = create_engine(url, connect_args={"check_same_thread": False})
@event.listens_for(configured_engine, "connect")
def enable_foreign_keys(connection, connection_record):
cursor = connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return configured_engine
engine = create_db_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Что изменилось и почему
Первое изменение: create_all убран из верхнего уровня модуля и перенесён в функцию init_db. create_engine создаёт ленивый объект — он не открывает файл и не выполняет SQL. Таблицы появятся только при явном вызове init_db. Когда pytest импортирует main, файл roomly_intervals.db не создаётся.
Второе изменение: логика создания engine вынесена в функцию create_db_engine. Это нужно fixture: ей потребуется свой engine с тем же PRAGMA-слушателем, но для другого файла. SQLAlchemy не переносит слушателей событий автоматически — каждый engine настраивается отдельно. Вызов create_db_engine с другим URL даст полноценный тестовый engine с включёнными внешними ключами.
get_db остался прежним: зависимость открывает Session из SessionLocal, выдаёт её обработчику и закрывает в finally. Именно эту функцию fixture заменит в тестах.
Запуск вручную
Для обычного ручного запуска копии сначала создайте схему:
python -c "from database import init_db; init_db()"
python -m uvicorn main:app --reload
Тесты эту команду не используют — они создают схему сами в отдельном файле.
create_all создаёт таблицы, которых ещё нет в указанной базе. Он не добавляет столбцы в уже существующие таблицы и не трогает другие базы.
Override get_db: отдельная Session на каждый запрос и восстановление после теста
Теперь есть функция create_db_engine, которую можно вызвать с путём к временному файлу. Осталось сказать FastAPI: «для тестов не вызывай get_db из database.py — вместо неё используй вот эту функцию». Это делает app.dependency_overrides.
Создайте рядом с main.py файл conftest.py — pytest автоматически загружает его перед тестами и делает все объявленные в нём fixtures доступными без явного импорта.
# conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import sessionmaker
from main import app
from database import create_db_engine, get_db
from models import Base
@pytest.fixture
def client(tmp_path):
database_path = tmp_path / "test.db"
test_engine = create_db_engine(f"sqlite:///{database_path.as_posix()}")
previous_overrides = dict(app.dependency_overrides)
try:
Base.metadata.create_all(bind=test_engine)
TestingSession = sessionmaker(bind=test_engine, autocommit=False, autoflush=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
finally:
app.dependency_overrides.clear()
app.dependency_overrides.update(previous_overrides)
test_engine.dispose()
database_path.unlink(missing_ok=True)
tmp_path — встроенная fixture pytest: она даёт отдельный временный каталог для каждого теста. Файл test.db внутри этого каталога живёт ровно столько, сколько нужно тесту.
previous_overrides = dict(app.dependency_overrides) сохраняет снимок текущего словаря. Это важно: если в проекте уже есть другие подмены (например, для конфигурации), их нельзя потерять и нельзя удалить чужой подменой. В finally словарь восстанавливается двумя строками: сначала очищается, потом заполняется сохранённым снимком.
app.dependency_overrides[get_db] = override_get_db — ключ здесь именно объект функции get_db, тот же самый, который передаётся в Depends внутри обработчиков. Строка "get_db" или вызов get_db() не сработают.
override_get_db открывает новую Session на каждый входящий запрос. Все запросы одного теста видят один и тот же файл базы, но каждый запрос получает свою Session — так же, как в боевом get_db.
Порядок завершения в finally важен: сначала завершается контекст TestClient, потом восстанавливаются overrides, потом test_engine.dispose() закрывает пул соединений engine, и только после этого удаляется файл. На Windows открытый файл базы нельзя удалить — dispose обязателен перед unlink.
Тесты
# test_rooms.py
def test_create_room(client):
response = client.post("/api/rooms", json={"name": "Альфа", "capacity": 4})
assert response.status_code == 201
room = response.json()
assert type(room["id"]) is int
assert room["name"] == "Альфа"
assert room["capacity"] == 4
response = client.get("/api/rooms")
assert response.status_code == 200
assert response.json() == [room]
def test_empty_rooms(client):
response = client.get("/api/rooms")
assert response.status_code == 200
assert response.json() == []
test_create_room создаёт комнату и сразу проверяет, что GET возвращает её. test_empty_rooms проверяет, что база пуста. Эти два теста проходят в любом порядке: каждый получает свою базу от своего запуска fixture, и запись из одного теста физически не существует в файле другого.
Запуск из папки testing_demo в том же окружении, где установлены pytest и httpx:
python -m pytest -q test_rooms.py
Оба теста пройдут. Файл roomly_intervals.db не появится. Если он существовал до запуска тестов, его содержимое останется нетронутым.

Попробуйте решить
После теста подмена get_db осталась в app.dependency_overrides. Следующий тест использует app без fixture client. Какая функция зависимости будет вызвана и почему это загрязняет окружение?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
