Урок курса

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 не появится. Если он существовал до запуска тестов, его содержимое останется нетронутым.

Два независимых запуска fixture client со scope function. В тесте A POST создаёт запись, а последующий GET того же теста видит её. В тесте B первый GET возвращает пустой список. Оба файла называются test.db, но находятся в разных tmp_path; обычная база проекта не открывается. Различие обеспечивают отдельный engine и override get_db, а не само создание TestClient.
Запросы одного теста используют его временную базу. Другой тест получает новый файл через отдельный запуск fixture.

Задача 1

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

До установки override импорт main вызвал Base.metadata.create_all с обычным engine. Может ли последующая подмена через app.dependency_overrides отменить это обращение к обычному SQLite-файлу?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Тестовый engine создан заново, а обработчик включения foreign_keys зарегистрирован только на обычном engine. Будет ли тестовый engine автоматически проверять ForeignKey благодаря этому обработчику?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Все Session теста закрыты, соединения возвращены в пул тестового engine. На Windows удаление временного SQLite-файла всё ещё даёт PermissionError. Какое действие показанной fixture освобождает эти соединения перед unlink?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

В тесте, использующем fixture client с try/finally, упал assert внутри теста. Что произойдёт с блоком finally и словарём app.dependency_overrides?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 6

Независимые проверки Roomly

Тесты Roomly создают учебные данные, но не должны обращаться к рабочей базе или зависеть от записей другого теста. Подготовьте для каждого теста отдельное пустое хранилище.

Запросы одного теста должны видеть созданные им записи. Другой тест начинает с чистой базы независимо от порядка запуска. После завершения, в том числе при обычном падении проверки, освободите временные ресурсы и восстановите прежние подмены зависимостей приложения.

Что уже дано

Перед вашим кодом предоставлены pytest, TestClient и полный самостоятельный API ниже. Обычный DATABASE_URL задаёт служебная подготовка: это собственный файл-маркер, изображающий прежнюю базу, к нему тесты не должны обращаться. create_db_engine создаёт ленивый engine с FK на каждом соединении; обычная init_db не вызывается при импорте. API и его фабрику переписывать не нужно.

from datetime import datetime, timezone
from sqlalchemy import create_engine, event, select, ForeignKey, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker, Session

class Base(DeclarativeBase):
    pass

class Room(Base):
    __tablename__ = "rooms"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    capacity: Mapped[int]
    bookings: Mapped[list["Booking"]] = relationship(back_populates="room")

class Booking(Base):
    __tablename__ = "bookings"
    id: Mapped[int] = mapped_column(primary_key=True)
    room_id: Mapped[int] = mapped_column(ForeignKey("rooms.id"))
    starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
    ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
    room: Mapped["Room"] = relationship(back_populates="bookings")

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):
        connection.execute("PRAGMA foreign_keys=ON")
    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()

def to_db_utc(value: datetime) -> datetime:
    if value.utcoffset() is None:
        raise ValueError("Timezone required")
    return value.astimezone(timezone.utc).replace(tzinfo=None)

from fastapi import FastAPI, Depends, HTTPException
from pydantic import field_validator, model_validator
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional

class RoomIn(BaseModel):
    name: str = Field(..., min_length=2, max_length=50)
    capacity: int = Field(..., ge=1, le=50)

class RoomSchema(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    capacity: int
class RoomPatch(BaseModel):
    name: Optional[str] = Field(None, min_length=2, max_length=50)
    capacity: Optional[int] = Field(None, ge=1, le=50)
class BookingIn(BaseModel):
    starts_at: datetime
    ends_at: datetime

    @field_validator("starts_at", "ends_at")
    @classmethod
    def require_timezone(cls, value: datetime) -> datetime:
        if value.utcoffset() is None:
            raise ValueError("Timezone required")
        return value.astimezone(timezone.utc)

    @model_validator(mode="after")
    def check_interval(self) -> "BookingIn":
        if self.ends_at <= self.starts_at:
            raise ValueError("End must be after start")
        return self
class BookingOut(BaseModel):
    id: int
    room_id: int
    starts_at: datetime
    ends_at: datetime

app = FastAPI()

@app.get("/api/rooms", response_model=list[RoomSchema])
def list_rooms(db: Session = Depends(get_db)):
    return db.scalars(select(Room).order_by(Room.id)).all()

@app.get("/api/rooms/{room_id}", response_model=RoomSchema)
def read_room(room_id: int, db: Session = Depends(get_db)):
    room = db.get(Room, room_id)
    if room is None:
        raise HTTPException(status_code=404, detail="Room not found")
    return room

@app.post("/api/rooms", response_model=RoomSchema, status_code=201)
def create_room(data: RoomIn, db: Session = Depends(get_db)):
    room = Room(name=data.name, capacity=data.capacity)
    db.add(room)
    db.commit()
    db.refresh(room)
    return room

@app.put("/api/rooms/{room_id}", response_model=RoomSchema)
def replace_room(room_id: int, data: RoomIn, db: Session = Depends(get_db)):
    room = db.get(Room, room_id)
    if room is None:
        raise HTTPException(status_code=404, detail="Room not found")
    room.name, room.capacity = data.name, data.capacity
    db.commit()
    db.refresh(room)
    return room

@app.delete("/api/rooms/{room_id}", status_code=204)
def delete_room(room_id: int, db: Session = Depends(get_db)):
    room = db.get(Room, room_id)
    if room is None:
        raise HTTPException(status_code=404, detail="Room not found")
    if db.scalars(select(Booking).where(Booking.room_id == room_id)).first() is not None:
        raise HTTPException(status_code=409, detail="Room has bookings")
    db.delete(room)
    db.commit()

@app.patch("/api/rooms/{room_id}", response_model=RoomSchema)
def patch_room(room_id: int, data: RoomPatch, db: Session = Depends(get_db)):
    room = db.get(Room, room_id)
    if room is None:
        raise HTTPException(status_code=404, detail="Room not found")
    for field in data.model_fields_set:
        if getattr(data, field) is None:
            raise HTTPException(status_code=422, detail="Fields cannot be null")
    for field in data.model_fields_set:
        setattr(room, field, getattr(data, field))
    db.commit()
    db.refresh(room)
    return room
def find_conflict(db: Session, room_id: int, new_start: datetime, new_end: datetime) -> Booking | None:
    start_utc = to_db_utc(new_start)
    end_utc = to_db_utc(new_end)
    statement = select(Booking).where(
        Booking.room_id == room_id,
        Booking.starts_at < end_utc,
        Booking.ends_at > start_utc,
    )
    return db.scalars(statement).first()
def public_booking(booking: Booking):
    return BookingOut(
        id=booking.id, room_id=booking.room_id,
        starts_at=booking.starts_at.replace(tzinfo=timezone.utc),
        ends_at=booking.ends_at.replace(tzinfo=timezone.utc),
    )

@app.post("/api/rooms/{room_id}/bookings", status_code=201, response_model=BookingOut)
def create_booking(room_id: int, data: BookingIn, db: Session = Depends(get_db)):
    if db.get(Room, room_id) is None:
        raise HTTPException(status_code=404, detail="Room not found")
    if find_conflict(db, room_id, data.starts_at, data.ends_at) is not None:
        raise HTTPException(status_code=409, detail="Booking conflict")
    booking = Booking(room_id=room_id, starts_at=to_db_utc(data.starts_at), ends_at=to_db_utc(data.ends_at))
    db.add(booking)
    db.commit()
    db.refresh(booking)
    return public_booking(booking)

@app.get("/api/rooms/{room_id}/bookings", response_model=list[BookingOut])
def list_bookings(room_id: int, db: Session = Depends(get_db)):
    if db.get(Room, room_id) is None:
        raise HTTPException(status_code=404, detail="Room not found")
    bookings = db.scalars(select(Booking).where(Booking.room_id == room_id).order_by(Booking.id)).all()
    return [public_booking(booking) for booking in bookings]

Следующие два теста также уже предоставлены. Они будут запущены настоящим pytest с вашей fixture; их assertions не являются TODO. Служебная переменная _roomly_force_failure только по отдельному запросу runner вызывает обычный assertfailure в первом тесте.

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"] == "Альфа" and room["capacity"] == 4
    response = client.get("/api/rooms")
    assert response.status_code == 200 and response.json() == [room]
    # Служебный запрос этого сбоя проверяет finally после обычного падения теста.
    if _roomly_force_failure:
        assert False, "Intentional assertion to exercise fixture cleanup"

def test_empty_rooms(client):
    response = client.get("/api/rooms")
    assert response.status_code == 200 and response.json() == []

Runner создаёт принадлежащую ему временную папку и запускает настоящий pytest, включая tmp_path и fixture teardown. Наблюдение записывает используемые Session, подключения, FK, dispose и удаление файла, не выполняя эти действия вместо fixture. После teardown сохраняется отчёт; затем runner аварийно освобождает утёкшие ресурсы неисправных вариантов только для безопасного завершения проверки. Эта поздняя уборка не исправляет оценённый отчёт. Ранее установлены подмена get_db и посторонняя подмена: во время теста первую нужно заменить, после теста обе восстановить. Скрытый grader получает JSON-наблюдения; писать runner, наблюдатели или готовые тесты не нужно.

Что нужно сделать

Определите pytest fixture с именем client и параметром tmp_path, с обычным scope="function". Создайте собственный SQLite-файл внутри tmp_path, engine через готовую create_db_engine и таблицы Base именно в этом engine. Фабрика тестовых Session должна быть связана с ним. Подмените в app.dependency_overrides тот же объект get_db, который используют обработчики. Подмена открывает новую Session на каждый запрос, выдаёт её через yield и гарантированно закрывает; запросы одного теста видят один файл.

Сохраните прежние overrides, откройте настоящий TestClient(app) через with и передайте его тесту через yield. В finally после выхода из клиентского контекста восстановите прежний словарь, вызовите dispose тестового engine и удалите только собственный файл. Файл может называться test.db или иначе. На обычной базе нельзя выполнять SQL, читать её строки, вызывать init_db, очищать или удалять её.

Оба готовых теста должны проходить в любом порядке и повторно. При отдельном обычном падении assert в первом тесте teardown обязан так же восстановить overrides и освободить ресурсы. Не изменяйте готовый API и тесты. Принудительное уничтожение процесса, сеть, новый алгоритм бронирования и управление базой вне tmp_path не входят в задачу.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После вашего кода runner обнаруживает верхнеуровневую fixture client и готовые тесты настоящим pytest. stdin не используется; print/stdout не требуется. Функцию fixture не вызывают вручную — её жизненным циклом управляет pytest. Не запускайте pytest.main или Uvicorn самостоятельно.

Первый тест создаёт комнату и затем видит её в GET. Второй тест видит пустой каталог, даже если идёт после первого. После каждого теста его файл удалён, а старые подмены зависимостей восстановлены.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку