Перейти к содержанию

212 уроков, 15 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 16 из 20 Средний 35 мин 130 XP

Тестирование 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 создаётся заново на каждый тест — вот он, механизм изоляции.

conftest.py настоящего проекта
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()                       # база исчезла вместе с движком
Файл для чтения: в песочнице нет папки проекта, где pytest находит conftest.py сам. Ниже мы повторим тот же паттерн в песочнице: тест-файл записывается кодом в рабочую папку, и pytest.main запускает его как обычный тест.

Паттерн песочницы такой: код-блок пишет тест-файл вызовом 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:

папка тестов ORM-проекта
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))
Проверь себя
0 / 5

1. Что означает строка подключения sqlite:// без пути?

2. Зачем фикстуре session нужен yield?

3. Как проверить, что commit при дубликате поднимет IntegrityError?

4. Почему фикстуре session не задают scope="module"?

5. Что означает exit: 1 после pytest.main?

Карточки терминов
Запомнено: 0 / 6
Практика

Напиши тест test_done_task_found: фикстура session со свежей in-memory базой, модель Task с полями title и done. Тест добавляет выполненную задачу «съесть торт», сохраняет и проверяет, что запрос filter_by(done=1) находит именно её.

practice.py
Вопросы и ответы по уроку

Как тестировать код на 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.