TDD для збережених процедур Oracle

На одному з наших недавніх проектів ми зіткнулися з серйозною проблемою. Веб-додаток, який ми розробляли, повинно було використовувати внутрішню базу даних фінансової організації. З міркувань безпеки, доступ був дуже обмежений: будь-які зміни необхідно було робити за допомогою збережених процедур, а читати дані — за допомогою уявлень. Таким чином, додаток повинно було виконувати складні маніпуляції даними, не маючи ніякого уявлення про їх структуру. Основною перешкодою для нас було те, що наш додаток потрапляло в залежність від великих і складних процедур, для яких не існувало автоматизованих тестів.
Погугливши трохи, ми виявили, що у штатному інструментарії Oracle SQL Developer [1] є функціонал для створення автоматизованих тестів. Ми тут же приступили до його вивчення. І хоча тести для самої складної процедури довелося створювати вже після її написання, цей інструментарій все ж допоміг нам усунути декілька помилок, а також істотно полегшив процес розширення функціоналу і рефакторинга. Нижче я наведу приклад використання TDD для побудови збережених процедур, а також поділюся досвідом в роботі з інструментарієм.

Приклад

Припустимо, у замовника є існуючий додаток, яке дозволяє його клієнтам виконувати відправку СМС-повідомлень. Ще одна команда розробляє новий додаток, яке повинно буде працювати паралельно з існуючим, тому було б добре мати загальне місце для бізнес-логіки.

Структура даних

Додаток використовує наступну структуру даних:
CREATE TABLE CLIENTS(
ID NUMBER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
NAME NVARCHAR2(255) NOT NULL,
BALANCE NUMBER(*,2) DEFAULT 0 NOT NULL,
IS_ACTIVE NUMBER(1) DEFAULT 0 NOT NULL,
IS_PREPAY NUMBER(1) DEFAULT 0 NOT NULL
);

CREATE TABLE MESSAGE_QUEUE(
ID NUMBER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
ID_CLIENT NUMBER NOT NULL,
SENDER VARCHAR2(20),
RECIPIENT VARCHAR(20),
MESSAGE NVARCHAR2(255) NOT NULL,
QUEUED_ON TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
SEND_ON TIMESTAMP WITH TIME ZONE NULL,
SENT_ON TIMESTAMP WITH TIME ZONE NULL
);

CREATE TABLE TRANSACTIONS(
ID NUMBER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
ID_CLIENT NUMBER NOT NULL,
VALUE NUMBER(*,2) NOT NULL,
TRANSACTION_TIME TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

Для стислості, визначення первинних і зовнішніх ключів опущені.

Налаштування оточення

Юніт-тестування SQL Developer використовує базу даних для зберігання тестів, їх налаштувань, бібліотеки, та результатів виконання. У цих цілях рекомендується створити користувача для тестування, потім створити на його базі даних репозиторій. Цей процес більш докладно описаний у документації юніт-тестування [2].

Термінологія тестування Oracle

Термінологія тестування, яку використовує Oracle дещо відрізняється від загальноприйнятої терминоголии xUnit [3]:








xUnit SQL Developer Коментар до SQL Developer Набір тестів Test Suite Може включати інші набори тестів і/або сценарії Тестовий сценарій Test Може тестувати тільки одну функцію або процедуру Тест Test Implementation Налаштування контексту (setup) Startup Process Доступна на рівні тесту і набору тестів Скидання контексту (tear down) Teardown Process див. вище
Далі в тексті я буду використовувати російський варіант термінології xUnit.

Несподіванки

Працюючи з додатком, ми виявили, що воно не завжди працює так, як ми очікували:
  • Іноді, всі пункти меню юніт-тестування виявлялися відключеними. У таких випадках необхідно вибрати пункт меню View→Unit Test
  • Всі тести всередині сценарію використовують загальний набір установки і скидання контексту, що цілком логічно. Але з-за того, що редагуються вони через вкладку тіста, створюється відчуття, що їх можна персоналізувати для кожного тесту окремо.

Розробка з допомогою тестування

Перш ніж ми зможемо почати, необхідно створити порожню процедуру, а інакше неможливо буде створити тест. І хоча список аргументів можна залишити порожнім, в цьому немає ніякої необхідності.
Спочатку, ми можемо припустити, що для того щоб відправити повідомлення, нам буде необхідний ідентифікатор клієнта, відправник, одержувач, а також тіло самого повідомлення. Також, нам необхідно сигналізувати результат виконання, скажімо, через вихідний параметр. За допомогою діалогу створення процедури можна отримати цілком підходить визначення:
CREATE OR REPLACE PROCEDURE QUEUE_MESSAGE(
V_ID_CLIENT IN NUMBER,
V_SENDER IN VARCHAR2,
V_RECIPIENT IN VARCHAR2,
V_MESSAGE IN NVARCHAR2,
V_IS_QUEUED OUT NUMBER)
AS BEGIN
NULL;
END QUEUE_MESSAGE;

У випадку з Oracle, має сенс ставити префікс для змінних, ім'я яких може збігатися з назвою поля, так як у випадку неясності, знаменита СУБД вирішить суперечку на користь поля. А щоб уникнути безладу, простіше давати префікс всім змінним без винятку.
Примітка

Якщо параметри процедури змінилися, то кожен її тестовий сценарій необхідно оновити вручну, натиснувши кнопку контекстного меню Synchronize Test...

Перший сценарій

Для спрощення нашого прикладу, припустимо, що вартість одного повідомлення —
0.03
якихось грошей. І, як це не дивно, для опису сценарію цілком підходить Gherkin:
Дано:
Активний пост-сплатний клієнт

Коли:
Він відправляє повідомлення

То:
Повертається позитивний результат,
І вартість повідомлення фіксується в журналі транзакцій,
До того ж, повідомлення додається в чергу.

найшвидший спосіб створити тест — клацнути правою кнопкою миші на процедурі в дереві об'єктів, потім вибрати пункт меню Create Unit Test.... У вікні можна одразу натиснути кнопку Finish. Сценарій QUEUE_MESSAGE з єдиним тестом повинен з'явитися в панелі Unit Test.
Налаштування контексту
Спочатку нам необхідно буде заповнити базу необходмыми даними. Для нас найбільш зручним виявилось використання режиму PL/SQL для установки і скидання контексту. Тим не менш, будь-який з варіантів легко використовувати повторно за допомогою публікації в бібліотеку. Щоб скопіювати існуючий крок з бібліотеки, досить вибрати його зі списку, потім натиснути кнопку Copy. А якщо потрібно використовувати його без змін, але замість кнопки Copy необхідно натиснути чекбокс Subscribe.
Увага!

Ідея використовувати існуючу БД для тестування може здатися привабливою. Здавалося б, зберіг дані в налаштуванні, і відновив при скиданні контексту… Проте слід мати на увазі, що якщо в процесі виконання тестів на будь-якому етапі сталася неочікувана помилка, то база даних виявиться в тому вигляді, в якому вона була під час помилки, і скидання контексту виконаний не буде. Тому краще всього використовувати чисту базу даних, яку не страшно і нескладно повністю відновити у разі псування структури даних.
Припускаючи, що ми працюємо з порожньою базою даних, для настоянки контексту, нам знадобиться всього лише одна вставка запису пост-оплатного клієнта. Її можна тут же зберегти в бібліотеці, назвавши Пост-сплатний клієнт.
Скидання контексту
Щоб мати можливість повторного прогону тестів, необхідно очистити додані дані. Однадко, в нашому випадку, можна просто очистити всі таблиці, що охоплюються тестами. Цей крок також потрібно зберегти у бібліотеку для подальшого використання.
Виклик
Безпосередньо виконання тесту визначається за допомогою завдання параметрів збереженої процедури. Тут же задаються і значення вихідних параметрів для перевірки. Перевірку вихідних параметрів можна відключити за допомогою чекбокса Test Result. Він відноситься до параметрами, заданими в таблиці, так і динамічно.
Увага!

З вигляду може здатися, що задавати параметри мишкою в таблиці дуже зручно, проте необхідно мати на увазі, що ця таблиця не підлягає копіюванню. Це особливо важливо для процедур з великою кількістю аргументів, так як для створення чергового тесту їх все доведеться заново задавати вручну, особливо коли новий тест відрізняється від поточного всього лише на одне значення. Динамічний запит (Dynamic Value Query), на відміну від таблиці, можна зберігати у бібліотеці, а потім можна або використовувати повторно, або копіювати.
Як зазначено вище, динамічний запит більш зручний у використанні. Варто також відзначити, що назва вихідних параметрів у запиті повинно бути доповнено знаком
$
в кінці назви:
select 1 as V_ID_CLIENT, '79052222222' as V_SENDER, '79161111111' as V_RECIPIENT,
'Гайда гуляти!' AS V_MESSAGE, 1 as V_IS_QUEUED$ from DUAL

Примітка

Щоб повернуться з режиму динамічного запиту в табличний, необхідно повністю очистити значення динамічного запиту.
Так як ми вказали перевірку вихідного параметра, то вже можна запустити сценарій, і побачити збій. Якщо все зроблено правильно, система повинна повідомити про помилку. Будь-який інший збій на цьому етапі означає некоректну налаштування.
найпростіший спосіб заспокоїти тест — нахабно вписати
1
вихідний параметр у тілі процедури:
SELECT 1 INTO IS_QUEUED FROM DUAL;

Твердження
Тест знову зелений, але ми ще не перевірили всі необхідні умови. Їх можна перевірити в інших тестах того ж сценарію. Перед тим як створювати новий тест, варто перейменувати існуючий з дефолтного "Test Implementation 1" в "Позитивний результат", а весь сценарій — в "Активний пост-сплатний клієнт відправляє повідомлення".
Важливо

Легко припустити, що кожен тест виконується всередині транзакції. Проте на ділі це виявилося не так. У разі виникнення непередбаченої помилки, база даних може опинитися в непределенном стані. Очікуваних помилок така поведінка не стосується.
Наша наступна перевірка буде поміщена в окремий тест для отримання більш тонкої зворотного зв'язку, однак, варто пам'ятати, що кожен новий тест буде витрачати час на налаштування і скидання контексту, а кожен збій перевірки забезпечується чітким повідомленням про його причини. Ми розділимо перевірки за різними тестами в цьому сценарії, а потім об'єднаємо всі перевірки в один тест у наступному сценарії.
Примітка

SQL Developer не дозволяє переглядати два тіста одночасно. При переході до іншого тесту в дереві, поточний тест замінюється новим у тій же панелі. Крім того, неможливо розбити цю панель на дві незалежно кадрів області. Однак, дуже зручно відкрити вихідний код процедури паралельно з вікном тесту для швидкого переходу між двома панелями.
Наступний тест повинен перевірити, що повідомлення розміщено у чергу. Так як налаштування і скидання контексту вже зазначено, нам необхідно використовувати динамічний запит з бібліотеки, і задати перевірку затвердження. Після того як ми скопіювали динамічний запит, може здатися, що перевіряти вже перевірений вихідний параметр ні до чого, і можна скинути чекбокс Test Result. Однак, якщо прогнати тести в такому стані, то буде видно, що один з тестів проігнорований. Особисто для мене проігнорований тест — символ незакінченої роботи, тому прапорець доведеться поставити на місце.
Існує кілька способів перевірки тверджень. Першим пунктом у списку — булевська функція. При створенні булевої функції, діалог надає цілком підходящий шаблон:
-- Please replace this code with either a boolean
-- expression like this:
-- RETURN FALSE;
-- or else a code block which returns a boolean value
-- similar to the following:
DECLARE
l_count NUMBER;
BEGIN
SELECT count(*) INTO l_count FROM dual;
IF l_count <> 0
THEN
RETURN TRUE;
ELSE
RETURN FALSE;
END IF;
END;

Для нашої перевірки ми можемо використовувати цей шаблон, замінивши
dual
на
MESSAGE_QUEUE
, потім застосувавши необхідні фільтри. Умова також доведеться змінити з
l_count <> 0
на
l_count = 1
для більшої точності. Після цього можна сміливо зберігати функцію у бібліотеку для подальшого використання.
Примітка

Всі записи в бібліотеці зберігаються відповідно до свого типу. Це означає, що, якщо в подальшому знадобиться використовувати, наприклад, перевірку твердження, необхідно буде пам'ятати не тільки його назва, але й тип. Це дуже швидко може виявитися досить незручним, особливо у великих проектах.
При прогоні тестів ми повинні побачити помилку. Її дуже легко виправити:
INSERT INTO MESSAGE_QUEUE(ID_CLIENT, SENDER, RECIPIENT, MESSAGE)
VALUES(V_ID_CLIENT, V_SENDER, V_RECIPIENT, V_MESSAGE);

Тепер можна переконатися, що всі тести проходять з успіхом.
Примітка

При роботі з тестами репозиторій блокується, тому по закінченні роботи необхідно або закрити SQL Developer, або закрити репозиторій (Deselect Repository).
І, наостанок, перевіримо запис транзакції. Для цього виберемо наступний тип валідації — порівняння результатів запитів (Compare Query Results). Як і випливає з назви, він працює дуже просто: потрібно вказати два запиту, результати яких співпадуть. Оскільки точну дату і час дізнатися неможливо, можна задовольнятися будь-які значення в межах 10 секунд:
-- Source query
SELECT 1 AS ID_CLIENT, 0.03 AS SUM_VALUE FROM DUAL

-- Target query
SELECT ID_CLIENT, SUM(VALUE) FROM TRANSACTIONS
WHERE TRANSACTION_TIME BETWEEN CURRENT_TIMESTAMP AND (CURRENT_TIMESTAMP - 1/24/6)
GROUP BY ID_CLIENT;

Після прогону тестів ми бачимо розпливчасту помилку
Validation одна недавня транзакція: Compare query results check differences found
. Де "одна недавня транзакція" — назва нашої останньої перевірки в бібліотеці. І хоча цей варіант вже є цінним інструментом, було б чудово, якщо б він міг показати чому саме результати відрізняються.
Додамо потрібний функціонал в нашу процедуру:
INSERT INTO TRANSACTIONS(ID_CLIENT, VALUE) VALUES(V_ID_CLIENT, 0.03);

Налагодження
Після чергового прогону тестів раптом з'ясовується, що помилка нікуди не поділася. Ви, напевно, вже помітили помилку в коді вище, проте в реальних умовах ситуації бувають куди більш складними. Так як різниці інструмент показує, доведеться з'ясовувати причину вручну. На жаль, налагоджувальний функціонал SQL Developer тут нічим допомогти не в змозі. Це означає, що нам доведеться прогнати тест без виконання скидання. Для цього можна створити ще один сценарій — налагоджувальний. А точніше два: один — без скидання, але з тим же динамічним запитом, що і в неробочому тісті — для того щоб розібратися в чому справа; а другий — без налаштування контексту, але зі скиданням — для того щоб прибрати за першим.
Після запуску першого сценарію можна подивитися вміст таблиці, і звіритися з перевірочним запитом. Тепер чітко видно, що проблема полягала саме в перевірочному запиті. Не забувши запустити другий сценарій для очищення даних, покращуємо умови тесту, і влаштовуємо повторний прогін. Тепер все в порядку. Налагоджувальні сценарії можна залишити на майбутнє, а перший закінчений сценарій можна помістити в новий набір тестів.

Другий сценарій

Тепер, коли у нас є сценарій успішної відправки повідомлення, ми можемо спробувати сценарій невдалої відправлення. Наприклад, коли пост-сплатний клієнт неактивний:
Дано:
Неактивний пост-сплатний клієнт

Коли:
Він відправляє повідомлення

То:
Повертається негативний результат,
і транзакція не фіксується,
і черга повідомлень залишається незмінною.

Необхідно створити новий сценарій. Нам також доведеться злегка підправити налаштування контексту і динамічний запит, але це вже набагато простіше, ніж створювати з нуля.
Для налаштування контексту копіюємо PL/SQL крок "Активний пост-сплатний клієнт", в якому замінюємо
1
на
0
і публікуємо в бібліотеці під назвою "Неактивний пост-сплатний клієнт". Повторюємо те ж для динамічного запиту, назвавши новий запит "Невідправлене повідомлення". Для скидання контексту використовуємо існуючий крок.
Після прогону тест має показати помилку. Її дуже легко виправити. Замінюємо
SELECT 1 INTO V_IS_QUEUED FROM DUAL
на
SELECT IS_ACTIVE INTO V_IS_QUEUED FROM CLIENTS WHERE ID=V_ID_CLIENT
— і все знову працює.
Потім необхідно перевірити, щоб транзакція не зберігалася. Для цього використовуємо наступний тип перевірки — порівняння таблиць (Compare tables). Спочатку може здатися, що порівнювати не з чим, проте, в налаштуванні контексту є можливість скопіювати існуючу таблицю під тимчасову. Нам це прекрасно підходить — можна скопіювати транзакції в тимчасову таблицю, а після виклику процедури порівняти результати. Головне — не забути цю таблицю видалити при скиданні контексту. Є два варіанти — відновити, потім удаилить, і просто видалити. Так як відновлювати нам нічого — оберемо другий варіант. Зверніть увагу, що як і у випадку з порівнянням запитів, єдиний варіант зворотного зв'язку — є збіг чи ні.
Помилувавшись на помилку після прогону тестів, можна подумати над рішенням. Наприклад, можна обернути вставку в умова, ісользуя свіжо-оновлений V_IS_QUEUED:
IF V_IS_QUEUED = 1 THEN
INSERT INTO TRANSACTIONS (ID_CLIENT, VALUE) VALUES (V_ID_CLIENT, 0.03);
END IF;

Компілюємо процедуру, проганяємо тести — все працює.
На закінчення, нам треба перевірити, що черга повідомлень залишилася без змін. І хоча руки свербіли відразу ж помістити вставку повідомлення всередину умови поруч зі вставкою транзакції, це було б заохоченням порушення дисципліни. Тому спочатку створимо додаткову перевірку для цього твердження. Наступний тип перевірки — Запит, не повертає записів (Query returning no rows). Так як ми повністю очищаємо всі дані після кожного тесту, досить буде вказати
SELECT * FROM MESSAGE_QUEUE
в якості такого запиту.
Прогін тестів показує помилку, яку ми з легкістю усуваємо, поміщаючи вставку всередину умови. І на цьому закінчується наш другий сценарій.

Висновки

SQL Developer можна використовувати для розробки збережених процедур методом TDD. Незважаючи на численні недоліки, цей пакет надає платформу для розробки збережених процедур, дозволяючи розробникам з легкістю і впевненістю змінювати і розширювати функціонал існуючих процедур.
На жаль, тестовий репозиторій можна створити тільки в СУБД Oracle. Крім того, спроби використовувати сторонні СУБД типу PostgreSQL або навіть MySQL в якості бази даних для тестування, закінчуються крахом підсистеми теститования. Також з'ясувалося, що використання SQL Developer в системах безперервної інтеграції викликає масу проблем, але це вже окрема історія.


[1] Oracle SQL Developer (англ.) — http://www.oracle.com/technetwork/developer-tools/sql-developer/overview/index.html
[2] Oracle SQL Developer Help: Unit Testing Repository (англ.) — https://docs.oracle.com/cd/E15846_01/doc.21/e15222/unit_testing.htm#RPTUG45067
[3] xUnit — https://ru.wikipedia.org/wiki/XUnit
Джерело: Хабрахабр

0 коментарів

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