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

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

Комментарии (22)


  1. DeathSAAD
    05.01.2022 11:31
    +1

    Правильно ли я понимаю, если бизнеслогика где-то в гуще кода делает подтверждение транзакции, то никакого отката не произойдет (в том числе на глубине) по подверженной транзакции, и как результат состояние бд для следующих тестов будет отличаться?


    1. Firemoon Автор
      05.01.2022 12:15
      +1

      Бизнес-логика в тестах имеет доступ только к внутренней транзакции. После коммита/роллбэка будет создана новая вложенная транзакция и тест продолжится.

      В конце теста произойдет откат внешней транзакции и изменения, сделанные во внутренних транзакциях, будут сброшены.


      1. alex1t
        05.01.2022 23:11
        +1

        Вообще как говорится, it depends. Во-первых, от движка БД, во-вторых, от бизнес логики. Если мне не изменяет память в MSSQL нет явной поддержки вложенных транзакций например (по крайней мере года 4 назад) и если внутри бизнес логики был Rollback Transaction, то он откатить и нашу внешнюю транзакцию если @@TRANCOUNT станет равным 0 - например так:

        BEGIN TRANSACTION -- outer
        EXECUTE TEST_SP
        ROLLBACK TRANSACTION -- outer
        
        ...
        
        PROCEDURE TEST_SP
        BEGIN
        BEGIN TRANSACTION -- inner
        ...
        ROLLBACK TRANSACTION --inner
        END

        В этом случае - за последствия не отвечаем


        1. DeathSAAD
          06.01.2022 11:09

          Если мне не изменяет память, то откат распределенных транзакций в mssql также невозможен.

          Например, сюда попадают прилинкованные бд (через odbc как вариант)


        1. funca
          06.01.2022 12:59

          Интересно есть-ли в природе базы данных, которые поддерживают nested transactions (или autonomous subtransactions) в таком синтаксисе?

          SQLAlchemy всю жизнь использует savepoints для иммитации вложенных транзакций. Со стороны кода все равно красиво, но на практике результат сильно зависит от конкретного движка и характера нагрузки (например https://postgres.ai/blog/20210831-postgresql-subtransactions-considered-harmful).


  1. feoktant
    05.01.2022 12:21

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

    Создание сотни пустых таблиц займет меньше секунды. Вы могли бы уточнить что значит "долгое действие", или "большое приложение"?)


    1. Firemoon Автор
      05.01.2022 12:41
      +1

      Это относительная величина.

      Для упрощения расчетов предположим, что создание сотни таблиц, а затем их удаление на рабочем компьютере занимает ровно 1 секунду. Допустим так же, что в приложении 6000 тестов. Таким образом одно только создание и удаление займет 6000 секунд (больше часа!).

      "Ускорим" операцию создания/удаления до 0.1 секунды. Даже так десять минут на создание/удалении схемы.

      Да, для "ночных" пайплайнов десять минут — незначительная величина, но если у вас тесты запускаются на каждый пуш в ветку, то получается как-то неэффективно.


      1. feoktant
        05.01.2022 16:05
        +2

        Тесты можно написать без удаления-создания схемы. Создайте один раз, но не позволяйте пересекаться данным. Так эти тесты можно будет еще и запаралелить. Сложно будет написать правильный assert, не доставая из таблицы все данные. Но оно того сто́ит.


        1. mih-kopylov
          06.01.2022 11:27
          +1

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


          1. funca
            06.01.2022 13:08

            Все зависит от характера использования базы. В OLAP база является скорее частью модели, где логика зашита непосредственно в самих данных. Поэтому тесты в изоляции слаборепрезентативны. Я думаю, что не стоит зацикливаться только на одном виде тестирования.


    1. alexprey
      06.01.2022 15:09

      У меня есть подробное исследование с конкретными примерами и цифрами, если вам интересно https://habr.com/ru/company/arcadia/blog/304322/


  1. feoktant
    05.01.2022 16:12
    +1

    Имитация базы данных

    В вашем примере нет имитации БД, вы мокаете работу ОРМ. При смене механизма доступа к данным, или самой бд, ваши тесты перестанут работать.

    Для имитации бд скорее нужен подход с Repository/DAO, которые ограничивают все возможности библиотеки, к тому, что нужно в вашей бизнес логике. И тогла бд можно симитировать коллекцией в памяти.

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


    1. slapboy
      06.01.2022 11:28
      +1

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


  1. ivanych
    06.01.2022 01:38

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


  1. mrk-andreev
    06.01.2022 09:30
    +1

    Вы упустили https://www.testcontainers.org/ , которые позволяют поднимать docker container с нужной бд.

    Для ускорения тестов можно использовать mount на tmpfs и использовать backup вместо выполнения миграций на каждый тест.


    1. feoktant
      06.01.2022 13:15

      tmpfs работает только в Linux, с Win/Mac не получится. Но это уже что-то.

      Интересно можно ли testcontainers запустить из питона. Скорее всего в каждом языке есть свой аналог


      1. mrk-andreev
        06.01.2022 13:33

        Интересно можно ли testcontainers запустить из питона. Скорее всего в каждом языке есть свой аналог

        Да, есть порт на Python - https://github.com/testcontainers/testcontainers-python .


    1. alexprey
      06.01.2022 15:13

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

      Я в свое время такую идею сразу же отбросил, но тогда и докер контейнеры только начинали зарождаться.


  1. dimkax94
    06.01.2022 11:28
    +1

    Вы уверены, что приведенные примеры в принципе относятся к unit-тестированию?

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

    Ваши примеры я бы назвал чуть иначе - интеграционное тестирование. Впрочем, вполне могу быть и не прав


    1. funca
      06.01.2022 13:16

      В этой области с терминологией все не однозначно: unit, component, integration, system. Название скорее зависит от цели тестирования, нежели фактической структуры SUT (system under test), хотя определенная корреляция все же просматривается.


    1. alexprey
      06.01.2022 14:51

      По базовым терминам, конечно же, при тестировании логики, если задействован внешний сервис (в данном случае база данных), это является интеграционным тестированием. Но никто не запрещает упростить взаимодействие с ним реализовать все необходимые условия, чтобы эти тесты максимально походили на юнит тестирование. Ну и опять же все зависит от цели тестов. Если приоритет именно в проверке логики нашего кода, в данном случае это запросы в бд, а не проверка логики взаимодействия (поведения при отказе бд, реконнекты, создание подключения, сериализатор и т.д.), то такой тест, в целом, также можно называть юнит.


  1. alexprey
    06.01.2022 14:45

    Хорошая статья, я в свое время тоже занимался этим вопросом https://habr.com/ru/company/arcadia/blog/304322/ но пришел к другому пути. Я для каждого теста создаю копию только необходимой части БД, это позволяет экономить кучу времени и решает некоторые проблемы с параллельным запуском тестов в рамках одного прохода.