Інтеграційні тести в дії. Розбираємо на прикладі з вебсервісом
Вітаю. Мене звати Ганна Касай. Я .NET-розробниця в компанії ITERA. Певний час моєю зоною відповідальності на посаді було написання інтеграційних тестів, тож в даній статті я хочу поділитись накопиченими досвідом і знаннями.
Ми з’ясуємо, що таке інтеграційні тести і чому їх варто реалізувати під час розробки багатокомпонентної системи. Також ми розглянемо низку корисних API, які наша команда використовує для тестування сервісів і додаткові інструменти, які дозволяють максимально автоматизувати і, відповідно, оптимізувати процес розробки.
Всю інформацію я подаватиму на прикладі програми, написаної для тестування вебсервісу.
Intro
Про інтеграційні тести можна багато розказати: підходи, техніки, найкращі практики тощо. Проте дана стаття несе скоріше практичний характер, ніж теоретичний, і має за мету продемонструвати гнучкість, можливості і переваги використання інтеграційних тестів.
Забігаючи наперед, розкажу, що саме на нас очікує:
- кодогенерація;
- взаємодія з браузером в інтеграційному тесті;
- проходження двофакторної автентифікації під час виконання програми;
- генерація одноразового паролю для проходження другого кроку автентифікації;
- обробка тимчасових збоїв під час тестування системи з подійно-орієнтованою архітектурою;
- альтернатива Xunit.Assert;
- Azure DevOps CI/CD.
Все це ми розглянемо на прикладі інтеграційного тесту для вебсервіса. В ньому перевіряється коректність роботи POST /checkid ендпоінту, який отримує запит з ідентифікаційним номером платника податків і, якщо він коректний, кладе його в базу даних і повертає нам id запису.
Солюшн програми містить xUnit проєкт з одним тестом, реалізованим згідно з патерном AAA, де:
1. Arrange: формування даних для тесту.
2. Act: автентифікація і авторизація, генерація токена доступу, запит до POST /checkId ендпоінту з коректними даними, пошук запису в базі даних, формування очікуваного результату.
3. Assert: перевірка коректності отриманого результату.
Integration tests
Інтеграційні тести — це один з різновидів автоматизованих тестів, задача яких полягає у визначенні коректності роботи складної системи, представленої взаємодією різних модулів.
Цілком імовірно, що одні модулі будуть реалізовані ізольовано від інших, можливо, навіть іншою командою розробників. Скоріше за все такі модулі і підтримуватись будуть неузгоджено, незалежно один від одного. В таких випадках вкрай важливо регулярно перевіряти їхню взаємодію і працездатність системи в цілому.
Складна система може охоплювати роботу з базою даних, сторонніми бібліотеками, вебсервісами, фреймворками тощо. Це означає, що інтеграційні тести дозволяють виявити проблеми не тільки у вашому коді, але і в зовнішніх системах, з якими взаємодіє ваша програма.
Інтеграційні тести найчастіше покривають роботу складної, багатокомпонентної системи, тому потребують підключення додаткових залежностей і залучення додаткових інструментів розробки. Про них далі і поговоримо.
Code generation
В інтеграційних тестах ми нерідко маємо справу з базою даних і сторонніми вебсервісами. Цілком імовірно, що ми б могли тестувати API з десятком ендпоінтів і комунікувати з декількома базами даних. За таких умов реалізувати моделі для інтеграцій самостійно стає затратним по часу.
А що станеться, якщо розробники тестованого вебсервісу раптово змінять модель запиту до ендпоінту? Тоді під час виклику API ми можемо отримати відповідь зі статусом, скажімо, 400 Bad Request.
Відповідно, для того, щоб інтеграційний тест знову коректно запрацював, нам треба внести правки в модель запиту. А якщо у нас немає доступу до репозиторію вебсервісу або правки вносяться часто, але нас про них не попереджають? Тоді тримати інтеграційний тест в актуальному стані стає складніше.
Тобто, під час розробки і підтримки інтеграційного тесту ми можемо зіткнутись з наступними проблемами: довготривалість реалізації моделей для інтеграції і складність їхньої підтримки в актуальному стані. Обидві проблеми вирішуються за допомогою кодогенерації.
Кодогенерація — це процес автоматичного створення коду програмами-генераторами на основі документації в json/yaml/xml та інших форматах представлення даних.
Кодогенерація допомагає швидко і точно реалізувати моделі для інтеграції з ресурсом і тримати їх в актуальному стані.
З ряду причин інтеграційний тест — це гарне середовище для використання кодогенерації:
- ми, найімовірніше, матимемо справу з великою кількістю моделей, складних в реалізації;
- ми зацікавленні лише у відтворенні моделей і не зацікавлені в їхній кастомізації (перейменуванні, зміні типів тощо);
- для нас важливо в будь-який момент часу швидко відтворити зміни в моделях, не вникаючи в суть цих змін, якщо це можливо;
Розглянемо кодогенерацію на прикладі тестового проєкту. За допомогою генератора Swagger Codegen в ньому реалізоване створення моделей для інтеграції з тестованою API. Все, що для цього потрібно — swagger-codegen-cli.jar файл і встановлена Java версії 7 або вище (в залежності від вимог).
Процес генерації винесено в окремий файл, таким чином можна швидко перегенерувати моделі за необхідністю:
Всі згенеровані моделі можна знайти за посиланням.
Варто додатково зупинитись на генерації моделей для роботи з базою даних. Представлений інтеграційний тест комунікує з Cosmos DB. Оскільки це NoSQL база даних, вона не має чіткої схеми, тому кодогенерація до неї не застосовна. Проте для реляційних баз даних це можливо реалізувати.
Наприклад, Entity Framework і Entity Framework Core дозволяють згенерувати класи-сутності та клас DbContext на основі схеми бази даних. В Entity Framework цей підхід називається Database First, а в Entity Framework Core — Scaffolding.
Implementation tools
Тепер, коли ми вже знаємо, що таке інтеграційний тест і чому його варто використовувати, настав час ознайомитись з його можливостями на практиці.
Ця глава являє собою огляд деяких корисних і потужних залежностей, які широко використовуються в інтеграційних тестах. Я розкажу про складнощі, з якими ми можемо зіткнутись під час реалізації тестів, і як саме названий інструмент допомагає справитися з ними.
Розглянемо деталі реалізації інтеграційного тесту, який перевіряє роботу POST /checkId ендпоінта деякого вебсервісу. Ендпоінт приймає ідентифікаційний номер платника податків і, якщо номер коректний, кладе його в базу даних і повертає id запису:
Під час виконання тесту компоненти програми взаємодіють наступним чином:
Першою цікавою задачею є процес проходження автентифікації. На цьому етапі виникають певні ускладнення:
- автентифікація потребує взаємодії з вебсторінкою через браузер;
- автентифікація може містити декілька кроків, тобто бути багатофакторною;
- ми можемо бути обмежені у виборі способу автентифікації.
Отже, можна зробити висновок, що чим більше факторів містить автентифікація і чим більше обмежень на способи її проходження, тим складніше це програмно реалізувати.
Розглянемо детальніше перший пункт: автентифікація потребує взаємодії з вебсторінкою через вебформи так, наче це робить справжній користувач.
Імітувати даний процес можна за допомогою бібліотек Selenium.WebDriver і WebDriverManager. Вони дозволяють запустити майже будь-який браузер з персональними налаштуваннями (мова інтерфейсу, режим інкогніто і т.д.) і взаємодіяти через код з UI-елементами так, як би ви це робили вручну:
З сильних боків, Selenium також використовуються для кросбраузерного тестування. Це означає, що його можна застосувати не лише для тестування бекенд логіки, але й для кросбраузерної сумісності сайту.
Перший фактор автентифікації передбачав введення пошти та паролю:
Після проходження першого кроку ми стикаємось з другим і третім ускладненнями: існує другий фактор автентифікації, і ми обмежені можливими способами його проходження: використання одноразового паролю, SMS з кодом на телефон, дзвінок на телефон:
Невеликим спойлером буде те, що, маючи бажання, пройти цей етап програмно можна будь-яким з представлених способів. Все залежить лише від того, скільки саме ресурсів ми готові витратити на написання інтеграційних тестів.
Найменш ресурсозатратним способом серед представлених є використання одноразового паролю, тому для другого кроку автентифікації оберемо саме його:
Отримати такий пароль можна за допомогою Otp.NET. Це бібліотека для генерації одноразових паролів на основі TOTP (Time-based one-time password) і HOTP (Hash-based one-time password) алгоритмів.
Обидва алгоритми використовують секретний ключ. Цей ключ статичний і отримується користувачем під час реєстрації акаунта в застосунку-аутентифікаторі (наприклад, Authenticator):
Детальніше про те, як пройти двофакторну автентифікацію з використанням Selenium і Otp.NET можна почитати за посиланням.
Після завершення автентифікації ми можемо зробити POST /token запит для генерації токена доступу. З ним ми коректно викликаємо POST /checkId ендпоінт і отримуємо ідентифікатор:
Використовуючи отриманий id, ми маємо дістати дані з бази даних — це наступна цікава і нетривіальна задача.
Тестований вебсервіс має подійно-орієнтовану архітектуру. Це означає, що користувачу і сервісу не потрібно чекати один одного, щоб продовжити виконувати задачі. Як наслідок, ми можемо отримати id запису раніше, ніж сам запис буде доданий в базу даних.
Проте сервіс гарантує, що запис з надісланим id буде доданий в базу даних, і зрештою, запитавши, ми зможемо його отримати. Цей тип гарантії називається кінцевою узгодженістю.
Кінцева узгодженість є обіцянкою, що через деякий час зміни, ініційовані користувачем, будуть реалізовані. Цей час, від отримання відповіді від сервісу до фактичного впровадження змін, називається вікном неузгодженості.
Різниця в часі між отриманням відповіді від вебсервісу і запитом до бази даних в тесті з великою ймовірністю буде менше, ніж тривалість вікна неузгодженості. Тобто на момент спроби витягнути дані з бази їх там ще не буде, ми нічого не отримаємо і тест впаде.
Цю проблему було вирішено за допомогою бібліотеки обробки тимчасових збоїв Polly. Вона дозволяє розробникам налаштувати певний шаблон виконання запитів, задати очікуваний результат, обробляти і обходити можливі помилки тощо.
В нашому випадку за допомогою Polly ми обійшли вікно неузгодженості шляхом реалізації ланцюга методів (HandleResult, WaitAndRetryAsync, ExecuteAsync) з заданою кількістю спроб і очікуваним результатом.
Природною мовою цей ланцюг читається як «повторюй дію раз за разом з вказаним часовим інтервалом, поки ми не отримаємо бажаний результат або не закінчиться кількість спроб»:
Після отримання результату ми переходимо до його порівняння з очікуваним. Для цього було використано FluentAssertions. Це .NET бібліотека, призначена для тих самих цілей що і Xunit.Assert, тобто для валідації об’єктів. Проте FluentAssertions має низку переваг.
По-перше, можливість будувати ланцюги методів. Таким чином можна легко відокремити перевірку одного об’єкту від перевірки іншого і покращити читабельність коду.
По-друге, опції, яких немає в Xunit.Assert, наприклад, порівняння об’єктів, перевірка вмісту колекції і т.д.
По-третє, API FluentAssertions дозволяє читати код як природну мову. Наприклад, actualResult.Should().BeEquivalentTo(expectedResult) краще сприймається оком, ніж Assert.Equal(actualResult, expectedResult).
Перевірка об’єктів в інтеграційному тесті:
Additional tools
Запуск інтеграційних тестів можна винести окремим етапом CI/CD пайплайну. Це може стати надзвичайно корисною практикою, якщо запускати інтеграційні тести після деплойменту. Таким чином, якщо тести впадуть після нового релізу, це допоможе виявити і локалізувати проблему одразу, за гарячими слідами.
Даний підхід, щонайменше, дозволяє зекономити час розробників і тестувальників, витрачений на виявлення проблеми, її документування і локалізацію, а також виправити помилку до того, як вона, будучи в релізі, призведе до негативних наслідків.
Як середовище для запуску пайплайну я використала Azure DevOps. Він зручний тим, що пропонує для всіх одну безкоштовну паралельну роботу тривалістю до 1800 хвилин на місяць.
Пайплайн для солюшена з інтеграційним тестом:
Даний пайплайн складається з чотирьох етапів:
1. Підставлення змінних. Не виключено, що для написання тестів вам доведеться використовувати конфіденційні дані. В описаному вище інтеграційному тесті такі дані використовуються для отримання токена доступу і підключення до бази даних.
Для безпечного зберігання конфіденційних даних в сервісах (AzureDevops, Jenkins тощо) існує механізм створення змінних (в тому числі і приватних). Такі змінні можна співвіднести зі змінними з конфігураційного файлу солюшена і підставити в них значення в процесі виконання пайплайна. Самим змінним в конфігураційному файлі можна надати будь-які значення:
Змінні в AzureDevops:
2. Встановлення Google Chrome. Як ми вже зазначили, Selenium WebDriver працює з браузером, і нам необхідно переконатись, що в середовищі, в якому запуститься тест, встановлений браузер, який використовується під час його роботи.
Даний інтеграційний тест запуститься в докер-контейнері, який збереться на основі імеджа mcr.microsoft.com/dotnet/sdk:7.0. В ньому немає Google Chrome, тому його треба встановити.
3. Збірка проєкту. В окремому докер-контейнері ми збираємо проєкт і виносимо окремим етапом, щоб переконатись, що він не містить помилок компіляції.
4. Запуск тестів. На останньому етапі ми запускаємо тест (також в окремому докер-контейнері), щоб переконатись, що він відпрацьовує без помилок.
Структура пайплайна під час виконання з логами для кожного етапу виглядає наступним чином:
Для зручного отримання результатів можна налаштувати сповіщення через webhook. Після відпрацювання пайплайну ви отримаєте сповіщення зі статусом успіху та іншими деталями в зручному для вас месенджері (Slack, Teams тощо):
Для візуалізації в AzureDevops також можна переглянути аналітику.
Статистика за тривалістю пайплайну з вказаною середньою тривалістю і найдовшим етапом:
Статистика успішності завершення пайплайну:
Conclusions
Отже, ми поговорили про інтеграційні тести і важливість їхнього використання, на прикладі реалізованого тесту розглянули корисні інструменти, які можна використати під час розробки.
Варто зазначити, що інтеграційні тести не обмежуються поданою в статті інформацією. Існують типи інтеграційного тестування, безліч підходів (мокінг, стабінг тощо) і фреймворки для їхньої реалізації (Moq, NSubstitute та інші).
Також тестові проєкти, як і будь-які інші, мають свої стандарти і найкращі практики: правила найменування тестових методів, патерни проєктування, правила імплементації (уникнення магічних констант (строк, чисел і т.д.), власної логіки у вигляді розгалужень і циклів тощо). Детальніше про це можна дізнатись на цьому ресурсі.
Інтеграційне тестування використовується на будь-яких рівнях, починаючи від взаємодії декількох компонент всередині однієї програми, закінчуючи тестуванням великої розподіленої системи. Під час тестування API, що є частиною розподіленої системи, можна навіть виявити проблеми в зовнішніх підсистемах, з якими взаємодіє тестована програма.
Інтеграційне тестування є важливою ланкою при розробці комерційних проєктів.
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів