- PVSM.RU - https://www.pvsm.ru -
В современном мире множество приложений используют трехуровневую архитектуру с базой данных в слоях данных. Наличие юнит-тестов обычно упрощает поддержку продукта, но присутствие базы данных в архитектуре заставляет разработчиков применять смекалку.
В этой статье я хочу провести обзор разных способов юнит-тестирования приложения с БД и рассказать о способе, который я не видел в русскоязычном сегменте интернета. Статья будет посвящена Python 3, pytest и ORM-фреймворку SQLAlchemy, но методы переносимы на другие инструменты.
Сперва определим контекст повествования. Примеры могут выглядеть синтетическими и неправдоподобными. Это сделано намеренно, чтобы не отвлекаться на детали реализации приложения. Более того, так код будет более понятным для читателей, незнакомых с Python.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Server(Base):
__tablename__ = 'example_server'
id = Column(Integer, primary_key=True)
ip = Column(String, nullable=False)
hostname = Column(String, nullable=False)
power_on = Column(Boolean, server_default='False')
В качестве тестируемой функции предложим ту, что выключает сервер. Сделаем ее простой и странной:
from sqlalchemy.orm import Session
def power_off(session: Session, server: Server) -> bool:
"""
Method tries to power off server
:param server:
:return: True if success, False otherwise
"""
if server.id % 2 != 0:
success = True
else:
success = False
if success:
server.power_on = False
session.commit()
return success
Итак, у нас есть небольшой отрывок приложения, которое совершает какую-то полезную работу и использует данные из базы. Как его можно протестировать?
Самый простой способ. В начале проекта обычно самый выгодный с точки зрения затрат времени, но в дальнейшем может вызвать множество проблем.
Достоинства:
Недостатки:
Не стоит принимать этот пункт как решение.
Приведенный пример достаточно простой, и обращение к ORM-фреймворку только одно при коммите у объекта сессии, так как информация о сервере передается в виде объекта. При такой архитектуре можно передать модифицированный объект Session. Например:
class MockSession(Session):
def commit(self):
pass
def test_mock():
mock = MockSession()
server = Server()
server.id = 1
server.power_on = True
assert power_off(mock, server) is True
assert server.power_on is False
Такой подход позволяет не использовать базу данных, а значит, тесты будут работать быстрее.
Достоинства:
Недостатки:
Последнее не является критичной проблемой, но, если проверка будет производиться, это повысит доверие к результатам тестов.
Когда нужна временная база данных, вспоминается несколько решений, умеющих хранить данные в памяти. Мне приходят на ум минимум два решения:
Очевидно, что SQLite во многом уступает «полноценным» базам данных, но это самый простой в настройке вариант, поэтому начнем с него. Теперь в тестах необходимо создать подключение к БД и сессию. Создаем соответствующие фикстуры.
HABR_TEST_DB_URL="HABR_TEST_DB_URL"
@pytest.fixture(scope="function")
def engine():
if HABR_TEST_DB_URL not in os.environ:
skip_reason_message: str = (
f"Environment var with name {HABR_TEST_DB_URL!r} is not provided. "
"Set this with a path to the real test database to run skipped tests."
)
pytest.skip(msg=skip_reason_message)
engine = create_engine(
os.environ[HABR_TEST_DB_URL],
echo=False
)
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
try:
yield engine
finally:
Base.metadata.drop_all(engine, checkfirst=True)
@pytest.fixture
def session(engine):
session = Session(engine)
yield session
Фикстура engine принудительно сбрасывает информацию в БД, которая может помешать тесту, и создает «чистую» схему в соответствии с описанием ORM-моделей. По завершении тестирования схема сбрасывается.
Обратите внимание, что схема подключения к БД передается через переменную окружения HABR_TEST_DB_URL. Фикстура engine предусматривает отсутствие данной переменной окружения и корректно обрабатывает эту ситуацию: отмечает тесты как пропущенные с говорящим сообщением об ошибке. Таким образом все тесты, использующие базу данных, будут пропускаться при ее отсутствии.
Теперь создаем фикстуру, которая представляет сервер.
@pytest.fixture
def server(session):
s = Server()
s.ip = '127.0.0.1'
s.hostname = 'home'
s.power_on = True
session.add(s)
session.commit()
return s
В отличие от предыдущего пункта, здесь необходимо указать все поля, которые не могут быть null. Иначе база данных просто не примет наш запрос. Эта фикстура однажды определяется и может быть переиспользована, например, в тестах редактирования записи. Напишем два простейших теста:
def test_presence(server):
assert server.ip == '127.0.0.1'
def test_embedded_db(session, server):
assert power_off(session, server) is True
assert server.power_on is False
Данный способ не оптимальный с точки зрения производительности: фикстура engine имеет область видимости function. Это значит, что подключение к БД будет создаваться перед началом теста и уничтожаться после его завершения.
Однако этот способ не ограничен SQLite. Схема подключения указывается в переменной окружения. Если есть нужный драйвер и правильные настройки для подключения, то можно использовать другую БД.
Достоинства:
Недостатки:
Но если мы уже используем базу данных, то почему бы не взять для тестов БД, аналогичную используемой в продакшене?
Правильнее всего использовать для тестирования ту базу данных, под которую разрабатывалось приложение. Это уменьшит разницу между окружением разработчика и тестовым окружением и позволит использовать особенности конкретной БД. Именно этот способ мы используем в Selectel для тестирования систем продукта «Выделенные серверы» [1].
Пересоздавать всю схему в БД — это долгое действие, особенно в больших приложениях. Сократить количество ненужных действий можно с помощью вложенных транзакций:
При запуске теста SQLAlchemy начинает вложенную транзакцию, которая откатится при завершении теста. Эта особенность ограничивает круг доступных баз данных, так как SQLite, например, вложенные транзакции не поддерживает.
Для работы данного способа необходимо определить собственную сессию и фабрику сессий.
class TestSession(SessionBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.begin_nested()
@event.listens_for(self, "after_transaction_end")
def restart_savepoint(session, transaction):
if transaction.nested and not transaction._parent.nested:
session.expire_all()
session.begin_nested()
Session = scoped_session(sessionmaker(autoflush=False, class_=TestSession))
Эта «магия» запускает вложенную транзакцию, позволяя приложению свободно выполнять любые запросы, в том числе коммиты. Но когда придет время, «родительская» транзакция откатится, как и изменения, произведенные тестом. Для этого фикстуру engine() нужно использовать из предыдущего примера с единственной поправкой: область видимости изменяется с function на session. А вот фикстура session претерпевает значительные изменения.
@pytest.fixture
def session(engine):
connection = engine.connect()
transaction = connection.begin()
Session.configure(bind=engine)
session = Session()
try:
yield session
finally:
Session.remove()
transaction.rollback()
connection.close()
Фикстура создает сессию и запускает «глобальную» транзакцию, прежде чем передать себя тестам. При этом код тестов не отличается от предыдущего примера со встраиваемыми базами данных.
Единственное различие заключается в том, что в этом способе идентификаторы объектов могут быть разными, а в предыдущем способе они чаще всего начинаются с единицы, так как схема и все последовательности (Sequence) для автоинкремента создаются и удаляются на каждый тест.
Достоинства:
Недостатки:
Этот способ также описан в секции о транзакциях и подключениях в документации SQLAlchemy [2].
В данной статье мы рассмотрели разные способы тестирования приложений, которые используют базу данных. Несмотря на то, что пример достаточно синтетический, код тестов можно переносить в собственные проекты. Для удобства я выложил все на GitHub [3].
Помните, что тестирование не дает 100% гарантии отсутствия ошибок, но значительно повышает как качество продукта, так и доверие пользователя.
Автор: Владимир
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/371053
Ссылки в тексте:
[1] «Выделенные серверы»: https://selectel.ru/services/dedicated/?utm_source=habr.com&utm_medium=referral&utm_campaign=Brand_Brand_Article_050122
[2] документации SQLAlchemy: https://docs.sqlalchemy.org/en/14/orm/session_transaction.html
[3] GitHub: https://github.com/Firemoon777/transactional-db-testing
[4] Источник: https://habr.com/ru/post/598499/?utm_source=habrahabr&utm_medium=rss&utm_campaign=598499
Нажмите здесь для печати.