SQLite в тестах FastAPI: почему появляется no such table

Тест создаёт таблицы в SQLite в памяти, а первый запрос FastAPI отвечает no such table. Команда create_all завершилась без ошибки. Проблема может быть в том, что обработчик открыл другое соединение: у обычных отдельных in-memory подключений SQLite разные базы.

База в памяти принадлежит соединению

Строка sqlite:// удобна для теста без файлов, но важно, какие соединения использует engine. TestClient выполняет приложение в другом потоке. Если таблицы созданы в одном подключении, а маршрут читает другое, имя таблицы не найдётся. Настройка check_same_thread=False снимает ограничение потока драйвера, но сама по себе не объединяет разные базы.

Для небольшого последовательного теста используйте StaticPool: он возвращает одно соединение, которое видит все таблицы и записи. Это отдельная настройка тестового окружения. Она не делает один connection подходящим для произвольной параллельной нагрузки; конкурентные тесты требуют выбранной стратегии базы и её ограничений.

Создаём отдельную базу для каждого приложения

Фабрика ниже создаёт engine, таблицу и endpoint. Два её вызова дают две независимые базы. Первый клиент видит свою запись, второй начинает с пустого состояния. Нужны SQLAlchemy 2, FastAPI и HTTPX. Все engine освобождаются после теста.

Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx, sqlalchemy. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.

from typing import Annotated
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

def make_app():
    engine = create_engine("sqlite://", poolclass=StaticPool,
                           connect_args={"check_same_thread": False})
    with engine.begin() as connection:
        connection.execute(text("CREATE TABLE notes (value TEXT NOT NULL)"))
    app = FastAPI()
    def get_db():
        with Session(engine) as session:
            yield session
    @app.post("/notes")
    def add(value: str, db: Annotated[Session, Depends(get_db)]):
        db.execute(text("INSERT INTO notes (value) VALUES (:value)"), {"value": value})
        db.commit()
        return {"saved": value}
    @app.get("/notes")
    def read(db: Annotated[Session, Depends(get_db)]):
        return db.scalars(text("SELECT value FROM notes ORDER BY value")).all()
    return app, engine

app1, engine1 = make_app()
app2, engine2 = make_app()
with TestClient(app1) as first, TestClient(app2) as second:
    assert first.post("/notes", params={"value": "A"}).status_code == 200
    assert first.get("/notes").json() == ["A"]
    assert second.get("/notes").json() == []
engine1.dispose()
engine2.dispose()
print("shared connection within test; independent databases across apps")

Как не замаскировать исходную ошибку

Не вызывайте create_all заново в каждом обработчике: это способ спрятать неверную границу ресурсов и лишняя работа на каждом запросе. Создание таблиц должно происходить при подготовке теста. В настоящем приложении изменения схемы обычно проводятся миграциями, а не автоматическим исправлением при чтении.

Проверьте, какой get_db действительно использует маршрут. Если вы подменили другую функцию с таким же именем, приложение всё ещё может обращаться к исходному engine. В dependency_overrides ключом служит объект функции, подключённый к Depends, а не её имя в строке. Оставьте в fixture явное создание engine и установку подмены рядом.

Когда лучше выбрать файл или отдельный сервер

Временный SQLite-файл проще для некоторых параллельных проверок, но имеет собственные ограничения блокировок. А проверка SQL, специфичного для PostgreSQL, должна выполняться на PostgreSQL: SQLite не подтверждает поведение другого диалекта. Выберите самый лёгкий стенд, который действительно проверяет заявленное свойство.

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

Следующая практика

В открытом уроке fixtures, временной базы и dependency override вы свяжете тестовый клиент с подготовленным хранилищем. Это удобное место, чтобы проверить границы engine и убедиться, что соседние тесты не делят записи. Перейдите к уроку о временной базе в тестах.

Чтобы собрать маршруты, хранение и тесты в одном приложении, откройте бесплатную программу FastAPI. Когда понадобится отдельная PostgreSQL и воспроизводимый запуск окружения, продолжите в Docker и Docker Compose: вы соедините сервис с базой, настроите сеть, том и проверку готовности.

Продолжить на практике