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




Вводное слово


Когда мы начали работу над новым проектом, была идея использовать TDD подход и, соответственно, писать юнит-тесты на каждый из используемых компонентов. Т.к. весь проект тесно взаимодействует с базой данных, то, конечно же, был поднят вопрос о том, что делать с компонентами, связанными напрямую с базой данных, и каким образом их тестировать. Быстрого способа решения данной проблемы мы не нашли и решили отложить этот вопрос на потом. Но меня он не переставал отпускать долгое время и в один прекрасный день мне удалось разработать фреймворк в рамках рабочего проекта, который позволял быстро протестировать взаимодействие с базой данных.

Система тестирования разрабатывалась в рамках и контексте рабочего проекта, в которой используется своя ORM система работы с базой данных, которая принимает параметризованные SQL запросы и на выходе самостоятельно разбирает полученные табличные данных в объекты. Что накладывает некоторые ограничения при разработке системы тестирования.

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



Мотивация


Зачем мы заставляем себя писать юнит-тесты? Для повышения качества разрабатываемого приложения. Чем больше разрабатываемый проект, тем больше вероятность сломать что-то, особенно во время рефакторинга. Юнит-тесты позволяют быстро проверить корректность работы компонентов системы. Но написание юнит-тестов для методов, взаимодействующих с базой данных – это не такая и простая задача, т.к. для корректной работы теста требуется настроенное окружение, а если точнее, то:

  1. Настроенный сервер баз данных
  2. Правильно настроенные строки подключения к базе
  3. Тестовая база данных со всеми необходимыми таблицами
  4. Правильно заполненные таблицы в тестовой базе данных


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



Описание проблемы


Перед тем как начать рассказ о разработке, еще хотелось бы рассказать, как в большинстве случаев тестируют такие вещи и к чему это может привести.

1. Тестирование запроса в базе данных


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

2. Тестирование через прямой вызов метода


Еще один из простых способов – мы просто открываем участок кода, который выполняется всегда в нашем приложении, например Main, вызываем метод, взаимодействующий с базой данных, устанавливаем точку останова и смотрим результат… Очень похоже на отладку через вывод информационных сообщений на экран.

3. Тестирование через пользовательский интерфейс приложения


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

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



Старт разработки


После того, как я убедил вас в необходимости данной системы, можно приступить и к описанию процесса её создания.

Как разработать систему автоматического тестирования?


Первые вопросы, которые у меня возникли – как разработать такую систему, каким требованиям она должна отвечать? Поэтому пришлось потратить время на исследование предметной области, несмотря на то, что я постоянно пишу юнит-тесты. Разработка системы тестирования – это совершенно другая сфера. В рамках данного исследования я пытался найти уже существующие решения, подыскать возможные способы реализации. Но сперва пришлось вспомнить принципы разработки юнит-тестов:

  1. Один тест должен соответствовать одному сценарию
  2. Тест не должен зависеть от времени и различных случайных величин
  3. Тест должен быть атомарным
  4. Время выполнения теста должно быть мало


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

Какой процесс тестирования подходит?


В результате длительного размышления был выработан простой 3-х этапный процесс:

  1. Инициализация – на данном этапе осуществлялось подключение к серверу баз данных, загрузка скриптов инициализации новой базы данных, а также её анализ. После этого создавалась новая пустая база данных, которая будет использоваться для запуска тестового метода. И конечно же её инициализация необходимой структурой.
  2. Выполнение – метод теста, которые отвечает подходу AAA.
  3. Завершение – в этот момент происходит освобождение использованных ресурсов: удаление используемой базы данных, завершение открытых подключений к серверу баз данных.


Схема разработанного процесса тестирования


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



Анализ разработанной системы


Производительность


Возможно сейчас многие могли подумать, что такой подход не будет быстро работать на больших данных, и они будут правы. Проект крупный, база данных большая, содержит множество различных таблиц и первоначальных данных, соответственно, время инициализации новой базы непозволительно большое. На таблице ниже представлен результат для одного теста:

Этап инициализации Время, мс Доля
Загрузка файла 6 < 1%
Подготовка скрипта 19 < 1%
Разбор скрипта 211 1%
Исполнение команд скрипта 14660 98%
Итого 14660


Как вы видите, на инициализацию одной тестовой базы данных уходит около 15 сек. А ведь в проекте будет написан явно не один тест. Если допустить, что в проекте написано около 100 тестов, то их общее время выполнения будет более чем полчаса! Сразу же стало ясно, что такие тесты не отвечают основным принципам – малое время выполнения.

Пришлось сесть за анализ производительности системы. Я выделил четыре основные секции инициализации теста, которые могут быть подвержены оптимизации. В результате получил таблицу, которая представлена выше, и как видно из неё, 98% времени уходит на этап, занимающийся отправкой команд скрипта инициализации тестовой базы данных. У нас было две основные идеи, которые помогли бы исправить данную ситуацию – использование транзакций и использование только необходимых нам таблиц из тестируемой базы данных.

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

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

Этап инициализации Время, мс Доля
Загрузка файла 6 1%
Подготовка скрипта 22 5%
Разбор скрипта 254 62%
Исполнение команд скрипта 134 32%
Итого 416


Как можно заметить после такой оптимизации удалось сохранить 97% времени! Хороший шаг на пути к быстрым тестам для тестирования запросов в базу данных. Из этой таблицы также видно, что возможности для оптимизации еще есть, но на данный момент такое время выполнения теста полностью удовлетворяет потребности и требования.

Разработка системы автоматической генерации данных


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

[TestMethod]
public void GetInboxMessages_ShouldReturnInboxMessages()
{
       const int validRecipient = 1;
       const int wrongRecipient = 2;
	
       var recipients = new [] { validRecipient };
	
       // Создаем основную запись и записываем её в базу, чтобы получить индекс
       var message = new MessageEntity();
       HelperDataProvider.Insert(message);

       // Создаем необходимые нам записи для проверки результата
       var validInboxMessage = new InboxMessageEntity()
       {
       	      MessageId = message.MessageId,
              RecipientId = validRecipient
       };
       var wrongInboxMessage = new InboxMessageEntity()
       {
              MessageId = message.MessageId,
              RecipientId = wrongRecipient
       };

       // Записываем их в базу данных
       HelperDataProvider.Insert(validInboxMessage);
       HelperDataProvider.Insert(wrongInboxMessage);

       // Тестируем метод
       var collection = _target.GetInboxMessages(recipients);

       Assert.AreEqual(1, collection.Count);
       Assert.IsNotNull(collection.FirstOrDefault(x => x.Id == validInboxMessage.Id));
       Assert.IsNull(collection.FirstOrDefault(x => x.Id == wrongInboxMessage.Id));
}


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

[TestMethod]
public void GetInboxMessages_ShouldReturnInboxMessages()
{
       const int validRecipient = 1;
       const int wrongRecipient = 2;
       const int recipientsCount = 2;
       const int messagesCount = 3;
       var recipients = new [] { validRecipient };

	// Создаем билдер для нужного типа данных
       DataFactory.CreateBuilder<InboxMessageEntity>()
		// Указываем связь между двумя сущностями, система автоматически создаст вторичную сущность и свяжет их
              .UseForeignKeyRule(InboxMessageEntity inboxEntity => inboxEntity.MessageId, MessageEntity messageEntity => messageEntity.MessageId)
		// Указываем возможные значения для поля получателя письма
              .UseEnumerableRule(inboxEntity => inboxEntity.RecipientId, new[] { validRecipient, wrongRecipient })
		// Указываем группу, образую связь N:N, два входящих письма к разным пользователям будут привязаны к одному основному сообщению
              .SetDefaultGroup(new FixedGroupProvider(recipientsCount))
		// Создаем нужное кол-во сущностей и отправляем их в базу данных
              .CreateMany(messagesCount * recipientsCount)
              .InsertAll();

	// Тестируем метод
       var collection = _target.GetInboxMessages(recipients);

       Assert.AreEqual(messagesCount, collection.Count);
       Assert.IsTrue(collection.All(inboxMessage => inboxMessage.RecipientId == validRecipient));
}


Всего несколько строк настройки генератора и на выходе мы получаем полностью готовую для тестирования базу данных со всеми необходимыми данными. Данная система построена на основе правил для сущностей, а также на основе группировки этих правил. Такой подход позволяет настраивать связи между сущностями вида N:N или N:1. В данной системе есть следующие правила:

  • DataSetterRule – позволяет задать конкретное значения для одного из полей сущности
  • EnumerableDataRule – позволяет задать список значений, которые будут чередоваться для разных сущностей. Например, для первой созданной сущности будет задано первое значение из списка, для второй – второе и т.д. с использованием цикличности
  • RandomDataRule – генерирует случайное значение из списка доступных, очень удобно использовать для генерации больших данных, чтобы протестировать сложный запрос на производительность
  • UniqueDataRule – генерирует случайное уникальное значение для заданного поля сущности. Это правило хорошо для случая, когда требуется создать набор сущностей, в которых на колонку в таблице наложено ограничение на уникальность
  • ForeignKeyRule – самое полезное правило, позволяет связать две сущности. Настраивая для этого правила группировку сущностей, можно в результате получить связи между сущностями вида N:N или N:1


Манипулируя этими правилами можно создавать различные наборы данных. После того, как будет вызван метод CreateMany или CreateSingle для создания сущности, билдер пройдется по всем необходимым правилам, заполнит сущность и после этого сохранит её в отдельный внутренний буфер. И только после того, как будет вызван метод InsertAll билдер отправит все сущности в базу данных. Схема работы представлена ниже:

Схема работы генератора данных


Внедрение системы в окружение проекта


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

К сожалению, данную систему не рекомендуется встраивать в процесс сборки, т.к. сервер, который занимается сборкой не должен иметь доступа к тестовым серверам баз данных, да и процесс тестирования сам по себе является ресурсоемким, поэтому и было принято решение перенести процесс запуска интеграционных тестов на тестовое окружение. Для этого был создан отдельный шаг развертывания для запуска тестов, с набором скриптов, которые автоматически запускали агент тестирования и анализировали результат его работы. Для запуска тестов использовался стандартный агент тестирования от Microsoft – MSTestAgent. Написание скриптов для анализа облегчил тот факт, что файл результата тестирования был записан в формате XML и поэтому весь анализ результатов был сведен к паре простых запросов на XQuery. На основе полученных данных строились отчеты, которые в последствии отправлялись на почту разработчикам или при необходимости в чат команды.

Схема процесса запуска тестов и оповещения разработчиков




Заключение


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

После того, как была разработана система автоматического тестирования запросов в базу данных, писать и тестировать код, связанный с запросами в базу данных стало намного проще. TDD подход можно спокойно применять не только для «классических» компонентов, но и для компонентов, которые тесно взаимодействуют с базой данных. После интеграции данной системы в окружение проекта следить за состоянием качества проекта стало намного проще, т.к. некорректное поведение компонентов выявляется во время билда проекта и сразу видно всем разработчикам.

И напоследок хотелось бы узнать, какими образом происходит тестирование уровня доступа к данным на ваших проектах?
Поделиться с друзьями
-->

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


  1. OlegKozlov
    29.06.2016 09:26

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

    Кстати, а зачем свой генератор данных, если были рассмотрены существующие решения для этого?


    1. alexprey
      29.06.2016 10:08
      +1

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

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

      Кстати, а зачем свой генератор данных

      Потому что используется своя велосипедная ORM. А так, была рассмотрена похожая система для EntityFramework. Оттуда и черпались лучшие идеи для своего генератора, с некоторыми улучшениями и доработки. Генератор для EF, например, умел генерировать сущности только для одной конкретной таблицы, поэтому приходилось поочередно генерировать данные и потом их связывать. В разработанной системе, все с помощью правил, включая связь по вторичным ключам.


  1. Bozaro
    29.06.2016 12:33
    +1

    Мои пять копеек:


    • для некоторых тестов допустимо использовать встраиваемые СУБД (в случае Java, к примеру, H2), но это требует поддержки нескольких СУБД в проекте и плохо увязывается с MySQL (у него крайне не стандартный синтаксис);
    • после разворачивания базы полезно сдвигать автоинкременты в таблицах, чтобы в случае обращения по чужому индексу, получать ошибку;
    • в большинстве случаев достаточно зачищать таблицы вместо пересоздания базы, это заметно быстрее.


    1. alexprey
      29.06.2016 12:58

      для некоторых тестов допустимо использовать встраиваемые СУБД (в случае Java, к примеру, H2), но это требует поддержки нескольких СУБД в проекте и плохо увязывается с MySQL (у него крайне не стандартный синтаксис)

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


      1. Bozaro
        29.06.2016 13:07

        SQLite совсем про другое. Я подозреваю, что для .NET должны быть адекватные SQL-базы, но они вряд ли годятся для проверки кода, ориентированного на MySQL.
        Кстати, MySQL очень медленно разворачивает базу в сравнении с PostgreSQL.


        1. alexprey
          29.06.2016 13:19

          Я конечно тесты не проводил, и не рассматривал вариант с постгрессом, задачу все-таки было необходимо решить в рамках стека технологий проекта.
          За наводку спасибо, заинтересовали. Надо будет как-нибудь побаловаться и провести пару экспериментов.


  1. wert_lex
    29.06.2016 13:02

    Статья конечно про РСУБД, но тем не менее поделюсь. Используем Mongo — там с этим все достаточно просто и прямо. Развернули новую БД, прогнали init script если есть необходимость, отработал тест, дропнули базу.
    У нас есть юнит тесты для слоя доступа — там на каждый тест поднимается отдельная база, и есть acceptance/functional тесты — там кроме БД поднимается еще и инстанс прилолжения и один инстанс приходится на один test suite, иначе таки да, ожидание становится мучительным.
    Стек — node.js, js, express, mongoose.

    Весь набор тестов не очень большой ~ 50 unit + 150 functional. Отрабатывают секунд за 20 на Core i5/8Gb/SSD. Все тесты и тест сьюты работают исключительно последовательно.


  1. Einherjar
    29.06.2016 13:50

    В плане использования одной базы на все тесты с нормальными рсубд MSSQL конечно в этом плане все проще — в TestInitialize создать TransactionScope, в TestCleanup вызвать Dispose на нем, и вообще не надо думать что там тестируемый код с базой делает.

    У нас схожие проблемы сравнительно недавно как раз были: и с кучей кода для инициализации одних и тех же тестовых объектов в разных тестах и с тупящей инициализацией базы после того как вся эта инициализация перенеслась в один общий для всех тестов модуль. И все это еще приправлено повторными выполнениями тестов с разными данными через атрибут DataSource. Последнее сильно портило картину, т.к. атрибут DataSource требует данные из той же базы и соотв. надо ее проинициализировать до того как движок запускающий тесты туда полезет за необходимыми значениями, а это происходит до того как срабатывают всякие там TestInitialize и ClassInitialize методы, да и вообще любой код теста, пришлось шаманить.


  1. drcolombo
    29.06.2016 14:02

    Добавлю про наш проект.

    • База — SQL Server. На каждый бранч в коде — отдельный инстанс.
    • Кроме «живой» базы, с которой работают разработчики, есть еще так называемый TestDatabaseTemplate — включает только костяк базы без данных, плюс таки данные в таблицах-справочниках. Обновляется база автоматически во время сборки проекта.
    • На каждый отдельно взятый функционал в приложении при AssemblyInitialize создается копия TestDatabaseTemplate с соответствующим суффиксом банально бэкапя TestDatabaseTemplate и восстанавливая его в новую базу, пересоздавая ее, если она уже есть. На всю эту инициализацию тратится примерно 2-3 секунды, так что можно сказать, что все происходит мгновенно :)
    • Есть тесты, что запускаются последовательно, а есть и такие, что запускаются осознанно параллельно с разными таймаутами (сейчас запускаем тест1-тест10 в 10 потоков, спустя n миллисекунд запускаем тест11, а может и еще пару десятков тоже параллельно). Приложение real-time, обрабатывающее таки довольно большие объемы данных, так что и тесты тоже должны симулировать такую вот многопоточность.
    • В solution'е порядка 300+ тестов, из них около 200+ интеграционные, т.е. ходящие в базу (для меня юнит-тест не имеет права ходить в базу — он таки тестирует отдельно взятый класс, максимум небольшую функциональность, где все хождения наружу должны быть закрыты mock'ами). На полный прогон всех тестов тратится порядка 3-5 минут.


    1. alexprey
      29.06.2016 14:11

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

      и тесты тоже должны симулировать такую вот многопоточность.

      Это больше на нагрузочное тестирование похоже.

      для меня юнит-тест не имеет права ходить в базу

      Да, так и должно быть. У нас они разделены и лежат в разных сборках, одни в *.UnitTests, другие в *.DataBaseIntegrationTests


  1. ush-alex
    29.06.2016 15:50

    Спасибо за материал.

    Есть несколько вопросов по ожидаемым результатам:
    — Где хранится эталонный результат?
    — Как решили вопрос со сравнением записей таблиц с большим количеством полей?


    1. alexprey
      29.06.2016 15:58

      Хороший вопрос. Как такого эталонного результата нет. Есть только некоторые характеристики, которым должен отвечать результат выборки.
      В качестве примера: если метод должен возвращать непрочитанные сообщения для определенного пользователя, то мы и проверяем в результате, что у объектов свойство IsRead установлено в false, и то, что они принадлежат запрашиваемому пользователю.
      А на вход данные генерируются каждый раз.


  1. om2804
    29.06.2016 20:09

    А юнит-тестирование ли это? Фактически Вы тестируете ещё и взаимодействие с БД. Очевидно, что это интеграционное тестирование


    1. alexprey
      29.06.2016 20:11

      Прошу обратить внимание на вводную часть статьи, где я говорю, что на самом деле это интеграционный тесты, а не модульные.


      1. om2804
        29.06.2016 20:33

        Ладно, но если помидор обладает «всеми удобствами и преимуществами» огурца, он всё равно остаётся помидором. И называть его огурцом не верно


  1. arxont
    30.06.2016 08:02

    А нельзя для такого кейса использовать снапшоты с виртаульной машины, с развёрнутой эталонной БД? Один тест — один снапшот? После прохода теста удалить.


    1. alexprey
      30.06.2016 12:40

      Оверхед… Вместо того, чтобы на уже готовом разворачивать новую базу данных, запускать отдельную виртуалку? Ресурсов будет съедаться значительно выше, да и время на копирование/старт виртуалки будет в разы больше, чем создать временную базу данных и пару таблиц в ней.


  1. Delphinum
    30.06.2016 20:18

    Работу РСУБД вообще не тестирую, верю что она уже протестированна разработчиками. Тестирую только взаимодействие с интерфейсом используемой РСУБД, что сводится к двум простым шагам:
    1. Инкапсулирую все запросы в классы/методы
    2. Тестирую получаемый в методе SQL или его часть (на пример условие), без пересылки его в базу

    Пока живу в таком контексте и беды не знаю.