Опишите паттерн использования фикстур pytest для изоляции тестов с базой данных

Ответ

Основная цель при тестировании работы с БД — изоляция тестов. Каждый тест должен выполняться в предсказуемом, чистом окружении, не зависящем от результатов предыдущих тестов. В pytest это достигается с помощью комбинации фикстур с разной областью видимости (scope) и механизма транзакций.

Паттерн состоит из двух ключевых фикстур:

  1. Фикстура для подключения (scope="session"): Создает одно подключение к базе данных на всю тестовую сессию. Это экономит время, так как не нужно устанавливать новое соединение для каждого теста.
  2. Фикстура для транзакции (scope="function"): Выполняется для каждого теста. Она начинает транзакцию, передает сессию в тест, а после его завершения откатывает транзакцию. Откат (rollback) отменяет все изменения (INSERT, UPDATE, DELETE), сделанные в тесте.

Пример реализации для SQLAlchemy

# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

# 1. Фикстура для создания движка, выполняется один раз за сессию
@pytest.fixture(scope="session")
def db_engine():
    # Используем БД в памяти для скорости
    engine = create_engine("sqlite:///:memory:")
    # Здесь можно создать таблицы
    # Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

# 2. Фикстура для создания сессии в транзакции для каждого теста
@pytest.fixture(scope="function")
def db_session(db_engine):
    connection = db_engine.connect()
    # Начинаем транзакцию
    transaction = connection.begin()
    # Создаем сессию
    SessionLocal = sessionmaker(bind=connection)
    session: Session = SessionLocal()

    yield session

    # После завершения теста откатываем транзакцию
    session.close()
    transaction.rollback()
    connection.close()

Пример использования в тесте

# test_user.py
from models import User # Предполагается, что есть модель User

def test_user_creation(db_session):
    # Arrange: создаем пользователя
    new_user = User(name="testuser", email="test@example.com")

    # Act: добавляем в сессию и коммитим (в рамках транзакции)
    db_session.add(new_user)
    db_session.commit()

    # Assert: проверяем, что пользователь появился в БД
    user_in_db = db_session.query(User).filter_by(name="testuser").first()
    assert user_in_db is not None
    assert user_in_db.email == "test@example.com"

# Следующий тест начнется с пустой БД, так как транзакция test_user_creation будет отменена.

Преимущества этого подхода:

  • Изоляция: Тесты не влияют друг на друга.
  • Скорость: Откат транзакции намного быстрее, чем удаление и пересоздание таблиц для каждого теста.
  • Чистота: Код теста работает с сессией так же, как и основной код приложения.

Ответ 18+ 🔞

А, ну вот, опять про эти ваши тесты с базой данных, блядь! Слушай, а ты знаешь, какой тут главный принцип? Чтобы каждый тест был как монах-отшельник, блядь, в своей пещере. Никаких следов от предыдущих, нихуя! Чистота, изоляция, предсказуемость — вот наш девиз, ёпта.

В pytest это делается через фикстуры, которые как бы разного калибра, понимаешь? Одни на всю сессию, другие — на каждый чих.

Схема простая, как три копейки:

  1. Фикстура-долгожитель (scope="session"). Это как один раз вставить ключ в замок и больше не вытаскивать. Создаёт подключение к базе на всю твою тестовую оргию. Экономия времени — овердохуищная, потому что не надо каждый раз стучаться в дверь к базе.
  2. Фикстура-однодневка (scope="function"). А вот эта штука отрабатывает на каждый тест. Её задача — начать транзакцию, дать тесту пошалить с сессией, а потом, как строгая мамка, всё откатить назад! Все эти INSERT, UPDATE, DELETE — коту под хвост. База снова девственно чиста.

Как это выглядит в коде для SQLAlchemy

# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

# 1. Движок на всю сессию. Один раз и всё.
@pytest.fixture(scope="session")
def db_engine():
    # Для скорости можно в памяти, конечно. SQLite — наш бро.
    engine = create_engine("sqlite:///:memory:")
    # Тут бы таблицы создать, если надо...
    # Base.metadata.create_all(engine)
    yield engine
    engine.dispose() # В конце всё приберём, не свинячь.

# 2. Сессия в транзакции для каждого теста. Вот где магия!
@pytest.fixture(scope="function")
def db_session(db_engine):
    connection = db_engine.connect()
    # Начинаем транзакцию, блядь! Точка невозврата (шутка, как раз возврат будет).
    transaction = connection.begin()
    # Рожаем сессию.
    SessionLocal = sessionmaker(bind=connection)
    session: Session = SessionLocal()

    yield session # Отдаём тесту, пусть творит что хочет.

    # А после теста — разгребаем последствия!
    session.close()
    transaction.rollback() # ВСЁ ОТМЕНИТЬ! Главная команда.
    connection.close()

И как этим пользоваться в тесте

# test_user.py
from models import User # Допустим, есть у нас моделька User

def test_user_creation(db_session):
    # Подготовка: лепим пользователя.
    new_user = User(name="testuser", email="test@example.com")

    # Действие: пихаем в сессию и даже коммитим! Но это в рамках нашей транзакции-пузыря.
    db_session.add(new_user)
    db_session.commit()

    # Проверка: а появился ли наш страдалец в базе?
    user_in_db = db_session.query(User).filter_by(name="testuser").first()
    assert user_in_db is not None
    assert user_in_db.email == "test@example.com"

# И следующий тест, ёпта, начнёт жизнь с чистого листа! Потому что транзакция откатилась, и все следы теста test_user_creation — в рот меня чих-пых, их нет!

А в чём, собственно, профит, спросишь?

  • Изоляция полная: Тесты друг другу не мешают, как будто в параллельных вселенных.
  • Скорость дикая: Откатить транзакцию — это ж не таблицы дропать и создавать заново. Мгновенно почти.
  • Реализм: Твой код в тестах работает с сессией ровно так же, как и в продакшене. Никакой магии, кроме нашей фикстурной.