Как подружить юнит-тестирование с базой данных
История о том, как разрабатывалась система автоматического тестирования методов, взаимодействующих с базой данных, с подробным описанием того, с какими подводными камнями пришлось столкнуться в процессе разработки и внедрения системы в окружение проекта.
Вводное слово
Когда мы начали работу над новым проектом, была идея использовать TDD подход и, соответственно, писать юнит-тесты на каждый из используемых компонентов. Т.к. весь проект тесно взаимодействует с базой данных, то, конечно же, был поднят вопрос о том, что делать с компонентами, связанными напрямую с базой данных, и каким образом их тестировать. Быстрого способа решения данной проблемы мы не нашли и решили отложить этот вопрос на потом. Но меня он не переставал отпускать долгое время и в один прекрасный день мне удалось разработать фреймворк в рамках рабочего проекта, который позволял быстро протестировать взаимодействие с базой данных.
Система тестирования разрабатывалась в рамках и контексте рабочего проекта, в которой используется своя ORM система работы с базой данных, которая принимает параметризованные SQL запросы и на выходе самостоятельно разбирает полученные табличные данных в объекты. Что накладывает некоторые ограничения при разработке системы тестирования.
Хочу сразу сказать, что далее я называю разрабатываемые тесты модульными, но на самом деле они являются интеграционными тестами, только обладающими всеми удобствами и преимуществами модульных тестов.
Мотивация
Зачем мы заставляем себя писать юнит-тесты? Для повышения качества разрабатываемого приложения. Чем больше разрабатываемый проект, тем больше вероятность сломать что-то, особенно во время рефакторинга. Юнит-тесты позволяют быстро проверить корректность работы компонентов системы. Но написание юнит-тестов для методов, взаимодействующих с базой данных — это не такая и простая задача, т.к. для корректной работы теста требуется настроенное окружение, а если точнее, то:
- Настроенный сервер баз данных
- Правильно настроенные строки подключения к базе
- Тестовая база данных со всеми необходимыми таблицами
- Правильно заполненные таблицы в тестовой базе данных
И все это выглядит достаточно сложно, поэтому большинство разработчиков даже и не задумывается об этом. Соответственно моей целью была разработка системы, которая упростила бы написания тестов до такой степени, чтобы они ничем не отличались от обычных юнит-тестов других компонентов проекта.
Описание проблемы
Перед тем как начать рассказ о разработке, еще хотелось бы рассказать, как в большинстве случаев тестируют такие вещи и к чему это может привести.
1. Тестирование запроса в базе данных
Перед тем как разработчик написал метод, который взаимодействует с базой данных, он пишет обычный SQL запрос с параметрами и проверяет его работу на своей локальной копии базы. Это самый быстрый и простой способ проверить запрос на ошибки. Но стоит обратить внимание, что с помощью такого подхода можно допустить ошибку во время переноса запроса в код проекта или в коде, выполняющем этот запрос. Как пример: можно по случайности пропустить инициализацию некоторых параметров, что приведет к некорректной работе запроса.
2. Тестирование через прямой вызов метода
Еще один из простых способов — мы просто открываем участок кода, который выполняется всегда в нашем приложении, например Main, вызываем метод, взаимодействующий с базой данных, устанавливаем точку останова и смотрим результат… Очень похоже на отладку через вывод информационных сообщений на экран.
3. Тестирование через пользовательский интерфейс приложения
Еще один весьма интересный и достаточно популярный способ протестировать написанный метод. Взять приложение, и выполнить цепочку последовательных действий, которые приводят к вызову вашего метода, взаимодействующим с базой данных. Способ достаточно сложный и не гарантирует, того, что запрос действительно выполнился, т.к. приложение могло вместо его выполнения обратиться к кэшу или выполнить другую операцию, потому что так было указано где-то в настройках.
И все эти сложности приводят к тому, что приходится тратить много времени на тестирование достаточно простых вещей. А, как известно, если это проверять долго, то скорее всего разработчик полениться его тестировать, что приведет к ошибкам. По статистике около 5% ошибок, связанных с неработающими запросами к базе данных, регистрируются в баг трекере. Но также я хочу отметить, что большинство таких ошибок обсуждается устно и достаточно часто, и к сожалению, их невозможно учесть в этой статистике.
Старт разработки
После того, как я убедил вас в необходимости данной системы, можно приступить и к описанию процесса её создания.
Как разработать систему автоматического тестирования?
Первые вопросы, которые у меня возникли — как разработать такую систему, каким требованиям она должна отвечать? Поэтому пришлось потратить время на исследование предметной области, несмотря на то, что я постоянно пишу юнит-тесты. Разработка системы тестирования — это совершенно другая сфера. В рамках данного исследования я пытался найти уже существующие решения, подыскать возможные способы реализации. Но сперва пришлось вспомнить принципы разработки юнит-тестов:
- Один тест должен соответствовать одному сценарию
- Тест не должен зависеть от времени и различных случайных величин
- Тест должен быть атомарным
- Время выполнения теста должно быть мало
После этого мне потребовалось разработать процесс тестирования, который бы отвечал этим требованиям.
Какой процесс тестирования подходит?
В результате длительного размышления был выработан простой 3-х этапный процесс:
- Инициализация — на данном этапе осуществлялось подключение к серверу баз данных, загрузка скриптов инициализации новой базы данных, а также её анализ. После этого создавалась новая пустая база данных, которая будет использоваться для запуска тестового метода. И конечно же её инициализация необходимой структурой.
- Выполнение — метод теста, которые отвечает подходу AAA.
- Завершение — в этот момент происходит освобождение использованных ресурсов: удаление используемой базы данных, завершение открытых подключений к серверу баз данных.
Таким образом, благодаря данному процессу, удалось удовлетворить требования, которым должны отвечать юнит-тесты. Т.к. каждый метод теста будет запускаться на выделенной базе данных, то мы можем сказать, что они являются атомарными и соответственно могут быть запущены параллельно.
Анализ разработанной системы
Производительность
Возможно сейчас многие могли подумать, что такой подход не будет быстро работать на больших данных, и они будут правы. Проект крупный, база данных большая, содержит множество различных таблиц и первоначальных данных, соответственно, время инициализации новой базы непозволительно большое. На таблице ниже представлен результат для одного теста:
Этап инициализации | Время, мс | Доля |
---|---|---|
Загрузка файла | 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()
// Указываем связь между двумя сущностями, система автоматически создаст вторичную сущность и свяжет их
.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 подход можно спокойно применять не только для «классических» компонентов, но и для компонентов, которые тесно взаимодействуют с базой данных. После интеграции данной системы в окружение проекта следить за состоянием качества проекта стало намного проще, т.к. некорректное поведение компонентов выявляется во время билда проекта и сразу видно всем разработчикам.
И напоследок хотелось бы узнать, какими образом происходит тестирование уровня доступа к данным на ваших проектах?
Комментарии (2)
29 июня 2016 в 09:26
0↑
↓
Суховато, конечно, но по делу, спасибо! Мы одну общую базу используем, больно тестов много, на каждый базу не наразворачиваешься. Вам повезло, что выбрав конкретные таблицы так получилось по производительности прыгнуть. Ну, в смыле, хорошо, что предметная область это позволяет. :)Кстати, а зачем свой генератор данных, если были рассмотрены существующие решения для этого?
29 июня 2016 в 10:08
+1↑
↓
Мы одну общую базу используем, больно тестов много, на каждый базу не наразворачиваешься.
Минус такого в том, что тест получается не чистым, т.к. результат может зависеть от выполнения предыдущих тестов. Тем не менее, такой подход тоже используется, например для проверки производительности, когда в тесте важно время выполнения запроса, а не возвращаемые данные. В настройках системы можно указать, чтобы он использовал статичный режим подключения к базе и тогда будет использоваться только одна конкретная база данных для тестирования.Кстати, а зачем свой генератор данных
Потому что используется своя велосипедная ORM. А так, была рассмотрена похожая система для EntityFramework. Оттуда и черпались лучшие идеи для своего генератора, с некоторыми улучшениями и доработки. Генератор для EF, например, умел генерировать сущности только для одной конкретной таблицы, поэтому приходилось поочередно генерировать данные и потом их связывать. В разработанной системе, все с помощью правил, включая связь по вторичным ключам.