Ответ
Основная цель при тестировании работы с БД — изоляция тестов. Каждый тест должен выполняться в предсказуемом, чистом окружении, не зависящем от результатов предыдущих тестов. В pytest это достигается с помощью комбинации фикстур с разной областью видимости (scope) и механизма транзакций.
Паттерн состоит из двух ключевых фикстур:
- Фикстура для подключения (
scope="session"): Создает одно подключение к базе данных на всю тестовую сессию. Это экономит время, так как не нужно устанавливать новое соединение для каждого теста. - Фикстура для транзакции (
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 это делается через фикстуры, которые как бы разного калибра, понимаешь? Одни на всю сессию, другие — на каждый чих.
Схема простая, как три копейки:
- Фикстура-долгожитель (
scope="session"). Это как один раз вставить ключ в замок и больше не вытаскивать. Создаёт подключение к базе на всю твою тестовую оргию. Экономия времени — овердохуищная, потому что не надо каждый раз стучаться в дверь к базе. - Фикстура-однодневка (
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 — в рот меня чих-пых, их нет!
А в чём, собственно, профит, спросишь?
- Изоляция полная: Тесты друг другу не мешают, как будто в параллельных вселенных.
- Скорость дикая: Откатить транзакцию — это ж не таблицы дропать и создавать заново. Мгновенно почти.
- Реализм: Твой код в тестах работает с сессией ровно так же, как и в продакшене. Никакой магии, кроме нашей фикстурной.