Как провести юнит-тестирование приложений с БД

Рассказываем про разные способы юнит-тестирования приложения с БД, в том числе о том, что мы используем при разработке продуктов Selectel.

В современном мире множество приложений используют трехуровневую архитектуру с базой данных в слоях данных. Наличие юнит-тестов обычно упрощает поддержку продукта, но присутствие базы данных в архитектуре заставляет разработчиков применять смекалку.

Статья посвящена 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

Такой подход позволяет не использовать базу данных, а значит, тесты будут работать быстрее.

Достоинства:

  • требует минимальной настройки;
  • наиболее быстрое выполнение тестов.

Недостатки:

  • неприменим или крайне сложен для некоторых архитектурных подходов;
  • код инициализации начальных данных растет с наличием связей в объекте;
  • запись в БД не производится, следовательно ORM не проверяет ограничения (constraint), заданные в модели.

Последнее не является критичной проблемой, но, если проверка будет производиться, это повысит доверие к результатам тестов.

Резидентная база данных

Когда нужна временная база данных, вспоминается несколько решений, умеющих хранить данные в памяти. Мне приходят на ум минимум два решения:

  • встраиваемая БД — SQLite;
  • легковесная БД, о которой я слышал от Java-разработчиков, — H2.

Очевидно, что 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. Схема подключения указывается в переменной окружения. Если есть нужный драйвер и правильные настройки для подключения, то можно использовать другую БД.

Достоинства:

  • использование ORM-фреймворка предоставляет больше возможностей для регрессионного тестирования;
  • не требует изменений в логике приложения, даже при наличии наследников от класса Session;
  • перед началом теста в БД лежит минимально необходимый набор данных.

Недостатки:

  • каждый тест вызывает создание и удаление всей схемы в базе данных;
  • если проект использует особенности конкретной БД, то тесты могут сломаться.

Но если мы уже используем базу данных, то почему бы не взять для тестов БД, аналогичную используемой в продакшене?

Транзакционное тестирование

Правильнее всего использовать для тестирования ту базу данных, под которую разрабатывалось приложение. Это уменьшит разницу между окружением разработчика и тестовым окружением и позволит использовать особенности конкретной БД. Именно этот способ мы используем в Selectel для тестирования систем продукта «Выделенные серверы».

Пересоздавать всю схему в БД — это долгое действие, особенно в больших приложениях. Сократить количество ненужных действий можно с помощью вложенных транзакций:

  • создаем схему в БД;
  • начинаем транзакцию;
  • вносим в базу исходные данные;
  • запускаем тест и получаем результат;
  • откатываем транзакцию;
  • повторяем пункты 2-5 для оставшихся тестов;
  • удаляем все данные.

При запуске теста 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) для автоинкремента создаются и удаляются на каждый тест.

Достоинства:

  • позволяет тестировать приложения на «родной» базе данных с использованием ее особенностей;
  • не требует изменения кода приложения.

Недостатки:

  • несовместим с некоторыми базами данных, например, для SQLite нужны «костыли».

Этот способ также описан в секции о транзакциях и подключениях в документации SQLAlchemy.

Заключение

В данной статье мы рассмотрели разные способы тестирования приложений, которые используют базу данных. Несмотря на то, что пример достаточно синтетический, код тестов можно переносить в собственные проекты. Для удобства я выложил все на GitHub.

Помните, что тестирование не дает 100% гарантии отсутствия ошибок, но значительно повышает как качество продукта, так и доверие пользователя.

Что еще почитать по теме

T-Rex 15 июня 2022

SATA SSD, M.2 и NVMe: разница между форм-факторами и протоколами передачи данных

Разбираемся в основных терминах, связанных с твердотельными накопителями. Начнем с жестких дисков и их отличий от SSD, подчеркнем различия 2,5-дюймовых SSD от M.2-накопителей, а также сравним SATA с N…
T-Rex 15 июня 2022
Михаил Фомин 10 июня 2022

Почему закон о персональных данных глубже, чем кажется

Рассказываем, кого на самом деле касается ФЗ-152 и как правильно хранить персональные данные в зависимости от их типа
Михаил Фомин 10 июня 2022

IP-телефония: что это и как работает

Благодаря IP-телефонии мы проводим утренние планерки в Zoom, созваниваемся с родителями по WhatsApp. Рассказываем, что это за технология, как она работает и почему так популярна.

Новое в блоге

Михаил Фомин 24 июня 2022

Docker Swarm VS Kubernetes — как бизнес выбирает оркестраторы

Рассказываем, для каких задач бизнесу больше подойдет Docker Swarm, а когда следует выбрать Kubernetes.
Михаил Фомин 24 июня 2022
T-Rex 22 июня 2022

Big Data: что такое большие данные и где они применяются

В статье расскажем о характеристиках и классификации больших данных, методах обработки и хранения, областях применения и возможностях работы с Big Data, которые дает Selectel.
T-Rex 22 июня 2022

Радикальная искренность в команде: как найти баланс между дружбой и требовательностью

Радикальная искренность — набор принципов, которые учат команду грамотной коммуникации и здоровому отношению к критике. Рассказываем, как применить их на практике.