Тестирование ORM: pytest и in-memory база
Ручная проверка не масштабируется: фикстура со свежей базой в памяти превращает каждое правило ORM в тест, который краснеет сам, как только код сломался.
Редакция Питоники
Пятнадцать уроков мы проверяли работу базы одним способом: запустил код, посмотрел на вывод, кивнул. Для учебного примера этого хватает, но у ручной проверки есть срок жизни — один запуск. Сегодня добавим в менеджер задач третий слой: модели описывают данные, функции приложения работают с ними, а тесты следят, чтобы ни одно из правил, которые мы выводили пятнадцать уроков, не было случайно нарушено завтрашним коммитом.
У ручной проверки два смертельных недостатка: она не повторяется и не жалуется. Тест из курса pytest устроен наоборот: он выполняется одинаково каждый раз и сам сообщает о поломке. База — идеальный материал для таких проверок: правила у неё строгие и формулируются словами assert — «запись сохранилась», «запрос нашёл добавленное», «дубликат отклонён».
from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
s.add(Task(title="отчёт"))
s.commit()
got = s.scalars(select(Task).where(Task.title == "отчёт")).one()
print("нашлась:", got.title)
нашлась: отчёт
Этот скрипт и есть будущий тест — осталось убрать print и поставить assert. Вопрос в другом: где брать базу для каждого теста, чтобы они не мешали друг другу. Ответ подсказывает сам SQLite: строка подключения sqlite:// без пути создаёт движок, чья база целиком живёт в памяти процесса и исчезает вместе с ним. Новый движок — новая пустая вселенная.
Фикстура: свежая база на каждый тест
В настоящем проекте подготовка базы выносится в фикстуру pytest: функция с декоратором @pytest.fixture создаёт движок и сессию, а yield отдаёт сессию тесту; код после yield выполняется после теста и убирает за ним. Фикстура без параметра scope создаётся заново на каждый тест — вот он, механизм изоляции.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models import Base
@pytest.fixture
def session():
engine = create_engine("sqlite://") # новая база на каждый тест
Base.metadata.create_all(engine)
with Session(engine) as s:
yield s # тест работает с сессией
engine.dispose() # база исчезла вместе с движком
Паттерн песочницы такой: код-блок пишет тест-файл вызовом Path.write_text, импортирует pytest и запускает его программно — pytest.main со списком аргументов, как будто набрали в терминале. Флаг -q даёт короткий отчёт, --no-header убирает шапку, -p no:cacheprovider запрещает pytest писать кэш. Возвратное значение — код выхода: 0, если все тесты зелёные, 1 — если есть падение.
from pathlib import Path
import pytest
Path("test_models.py").write_text('''\
from sqlalchemy import create_engine, String, UniqueConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
import pytest
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
@pytest.fixture
def session():
engine = create_engine("sqlite://") # свежая база на каждый тест
Base.metadata.create_all(engine)
with Session(engine) as s:
yield s
engine.dispose()
def test_model_saved(session):
session.add(User(name="Анна"))
session.commit()
assert session.get(User, 1).name == "Анна"
def test_added_visible_in_query(session):
session.add(User(name="Борис"))
session.commit()
found = session.query(User).filter_by(name="Борис").one()
assert found is not None
def test_duplicate_name_rejected(session):
session.add(User(name="Вера"))
session.commit()
with pytest.raises(IntegrityError):
session.add(User(name="Вера"))
session.commit()
''', encoding="utf-8")
rc = pytest.main(["test_models.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.38s exit: 0
Читаем отчёт построчно: три точки — три зелёных теста, каждый со своей новой базой; строка 3 passed — итог; exit: 0 — код выхода, по которому сборка понимает, что всё хорошо. Первый тест проверяет сохранение (модель создаётся и читается по ключу), второй — что запрос возвращает добавленное, третий — что уникальность стережёт сама база и поднимает IntegrityError, который pytest.raises ловит как ожидаемое исключение.
| Строка паттерна | Зачем |
|---|---|
| Path.write_text(...) | песочница создаёт тест-файл кодом; в проекте он уже лежит на диске |
| pytest.main([...]) | программный запуск pytest — как команда в терминале |
| -q | короткий отчёт: точки вместо имён тестов |
| --no-header | без шапки сессии с путями и версией |
| -p no:cacheprovider | не создавать служебный каталог .pytest_cache |
| int(rc) | код выхода: 0 — зелёный прогон, 1 — есть падение |
Когда тестов становится много, их раскладывают по файлам с говорящими именами — pytest собирает всё по конвенциям имён из урока про обнаружение тестов, а общие фикстуры складывает в один conftest:
tests/
conftest.py # общая фикстура session со свежей базой
test_models.py # схема: сохранение, ограничения, связи
test_queries.py # фильтры, сортировки, агрегации
test_app_layer.py # функции слоя приложения: CRUD целиком
Падающий тест — это тоже результат
Ценность сьюта проверяется не на зелёном прогоне, а на сломанном. Добавим тест с намеренно неверным ожиданием — два задания, а добавили одно — и посмотрим, как pytest предъявляет жалобу.
from pathlib import Path
import pytest
Path("test_wrong.py").write_text('''\
from sqlalchemy import create_engine, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
import pytest
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
@pytest.fixture
def session():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
yield s
engine.dispose()
def test_two_tasks(session):
session.add(Task(title="отчёт"))
session.commit()
assert session.query(Task).count() == 2
''', encoding="utf-8")
rc = pytest.main(["test_wrong.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
F [100%] =========================== short test summary info =========================== FAILED test_wrong.py::test_two_tasks - assert 1 == 2 1 failed in 0.38s exit: 1
Буква F вместо точки, строка FAILED с именем теста и сама жалоба: assert 1 == 2. Красный тест — это не ошибка программы, это точное сообщение о том, где реальность разошлась с ожиданием: в базе одна задача, а тест требовал две. Флаг --tb=no убирает развёрнутый трейсбек, оставляя короткую сводку — её достаточно, чтобы понять, что сломалось.
Изоляция: цена общей базы
Что будет, если схалтурить и создать движок один раз на весь файл — scope="module"? Первому тесту достанется пустая база, второму — уже замусоренная первым. Свежая база на каждый тест — это не роскошь, а изоляция: тест не знает, что было до него, и не оставляет следов после.
from pathlib import Path
import pytest
Path("test_shared.py").write_text('''\
from sqlalchemy import create_engine, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
import pytest
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
@pytest.fixture(scope="module")
def session():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
yield s
engine.dispose()
def test_first_adds(session):
session.add(Task(title="отчёт"))
session.commit()
assert session.query(Task).count() == 1
def test_second_expects_clean(session):
assert session.query(Task).count() == 0
''', encoding="utf-8")
rc = pytest.main(["test_shared.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.F [100%] =========================== short test summary info =========================== FAILED test_shared.py::test_second_expects_clean - assert 1 == 0 1 failed, 1 passed in 0.38s exit: 1
Механику изоляции легко увидеть и без pytest: два движка sqlite:// — две независимые базы, хотя код один и тот же. Запись, сделанная в первой, для второй не существует.
from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
def fresh_session():
engine = create_engine("sqlite://") # каждая база - своя вселенная
Base.metadata.create_all(engine)
return Session(engine)
first = fresh_session()
first.add(Task(title="единственная"))
first.commit()
second = fresh_session()
print("в новой базе задач:", len(second.scalars(select(Task)).all()))
в новой базе задач: 0
Фикстуры умеют складываться друг на друга: тест, которому нужны данные, принимает две фикстуры сразу — session даёт базу, а seeded добавляет в неё заготовку. Так сьют растёт без дублирования подготовки.
from pathlib import Path
import pytest
Path("test_stacked.py").write_text('''\
from sqlalchemy import create_engine, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
import pytest
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
done: Mapped[int] = mapped_column(default=0)
@pytest.fixture
def session():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
yield s
engine.dispose()
@pytest.fixture
def seeded(session):
session.add_all([Task(title="спеть", done=1), Task(title="станцевать")])
session.commit()
return session
def test_filter_undone(seeded):
undone = seeded.query(Task).filter_by(done=0).all()
assert [t.title for t in undone] == ["станцевать"]
def test_filter_done(seeded):
done = seeded.query(Task).filter_by(done=1).all()
assert [t.title for t in done] == ["спеть"]
''', encoding="utf-8")
rc = pytest.main(["test_stacked.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.07s exit: 0
| Что проверяем в ORM-слое | Как |
|---|---|
| модель сохраняется и читается | add, commit, get по ключу |
| запрос возвращает добавленное | select / filter_by + assert по списку |
| ограничения стережёт база | pytest.raises(IntegrityError) вокруг commit |
| изменение и удаление доходят до базы | атрибут + commit, delete + commit, проверка свежим запросом |
Сьют из этого урока переезжает в финальный проект раздела почти без изменений: там к нему добавится conftest с общей фикстурой и тесты на связи. А про то, как устроены фикстуры под капотом — yield, области видимости, автозапуск, — в отдельном уроке курса pytest.
Тест живёт вечно: он выполняется одинаково при каждом прогоне и сам сообщает о поломке, не дожидаясь, пока её заметит пользователь.
Сначала предскажи ответ в голове — это главный навык программиста.
from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
def fresh_session():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return Session(engine)
first = fresh_session()
first.add(Task(title="а"))
first.commit()
second = fresh_session()
second.add(Task(title="б"))
second.commit()
print(len(first.scalars(select(Task)).all()) + len(second.scalars(select(Task)).all()))
from pathlib import Path
import pytest
Path("test_mini.py").write_text('''\
def test_addition():
assert 2 + 2 == 5
''', encoding="utf-8")
rc = pytest.main(["test_mini.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print(int(rc))
1. Что означает строка подключения sqlite:// без пути?
2. Зачем фикстуре session нужен yield?
3. Как проверить, что commit при дубликате поднимет IntegrityError?
4. Почему фикстуре session не задают scope="module"?
5. Что означает exit: 1 после pytest.main?
Напиши тест test_done_task_found: фикстура session со свежей in-memory базой, модель Task с полями title и done. Тест добавляет выполненную задачу «съесть торт», сохраняет и проверяет, что запрос filter_by(done=1) находит именно её.
Как тестировать код на SQLAlchemy с pytest?
Завести фикстуру, которая на каждый тест создаёт движок sqlite:// (база в памяти), выполняет create_all и отдаёт сессию через yield. Тесты принимают фикстуру параметром и проверяют правила работы с базой через assert: сохранение, выборки, ограничения. Уборка — engine.dispose() после yield.
Почему для тестов берут in-memory базу?
Она создаётся мгновенно и исчезает сама: не нужно ни удалять файлы, ни чистить таблицы между тестами. Каждый движок sqlite:// — независимая пустая база, поэтому тесты изолированы друг от друга и идут быстро.
Как проверить, что база отклонила дубликат?
Обернуть commit в with pytest.raises(IntegrityError): — тест пройдёт, только если нарушение уникальности действительно поднимет исключение. Голый try/except не годится: он с одинаковой готовностью проглотит и ошибку, и её отсутствие.
Тесты падают по-разному при повторных прогонах — в чём дело?
Почти всегда это общая база: фикстура со scope="module" или глобальный движок оставляют данные от прошлого теста. Верни function-область видимости по умолчанию — каждый тест должен получать свежую in-memory базу и не зависеть от порядка выполнения.
Понравился урок? Сошлитесь на него
«Свежая база на каждый тест — это не роскошь, а изоляция: тест не знает, что было до него, и не оставляет следов после.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 6
yield-фикстуры: уборка после теста (setup и teardown)
Тест создал файл — и кто-то должен убрать за ним. yield-фикстура держит подготовку и уборку в одной функции, а уборку выполняет всегда, даже когда тест упал.
SQLAlchemy · Урок 20
Проект: менеджер задач на ORM с тестами
Финал курса: собираем менеджер задач — модели со связью, CRUD, доска и тест-сьют, который проверяет всё это шестью зелёными тестами.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
SQLAlchemy · Урок 2
Подключение к SQLite: create_engine
Строка подключения — это адрес базы. Разбираем sqlite:///:memory: против файла, ленивый движок и журнал echo, который показывает настоящий SQL.
sqlalchemy sqlite create_enginesqlalchemy подключение к базе данных