Як подружити юніт-тестування з базою даних

Як подружити 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(одержувачі);

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(одержувачі);

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 підхід можна спокійно застосовувати не тільки для «класичних» компонентів, але і для компонентів, які тісно взаємодіють з базою даних. Після інтеграції даної системи в оточення проекту стежити за станом якості проекту стало набагато простіше, т. к. некоректну поведінку компонентів виявляється під час білду проекту і відразу видно всім розробникам.

І наостанок хотілося б дізнатися, якими чином відбувається тестування рівня доступу до даних на ваших проектах?
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.