Перестаньте тестувати свій код!

pic

Полювання на баги по-новому!

Я опублікував цю статтю всередині компанії Xendit трохи менше року тому. Це призвело до формалізації того, що ми тепер називаємо "тестуванням на рівні сервісів" (service-level testing). Тепер, коли пил трохи вщух, я вважаю цікавим поділитися цією ідеєю з ширшою аудиторією.

Вступ

Часом ми чуємо, що наш вихідний код — це цінний актив. Однак я більше схиляюся до думки, що наш код — це зобов’язання. Те, що робить код для нас, дійсно є активом, але якщо б ми могли досягти тих самих результатів з меншим обсягом коду, це було б набагато краще!

Варто сказати правду: дуже рідко буває так, що клієнти готові платити нам за написання коду. За винятком рідкісних випадків, сам код не приносить цінності для них. Натомість, ми використовуємо код як один з багатьох інструментів, щоб принести користь клієнтам.

Сьогодні багато розробників працюють у невеликих командах, які розгортають колекцію мікросервісів (micro-services) або мікро-фронтендів (micro-frontends). Звісно, клієнти не платять безпосередньо за це, але принаймні одиниця доставки (unit of delivery), яку команда розробляє, — це те, за що вона відповідає. Тож підкреслимо це:

Одиниця вашої роботи — це ваш сервіс, а не ваш код.

Контейнеризація та мікросервіси докорінно змінили підхід до розробки ПЗ. Наша філософія тестування, однак, досі не пристосувалася до цих змін і часто залишається "застиглою" на найкращих практиках епохи монолітів.

pic

З великою потужністю приходять великі рахунки за AWS

Окістеніння, спричинене тестуванням

Ми всі бачили це в своєму коді: кожен клас, як-от Stuff, має свій unit-тест TestStuff і реалізує інтерфейс IStuff, щоб його було легко замокати (mock) під час тестування інших класів, таких як OtherStuff.

Результат? Код, що підлаштовується під обмеження наших інструментів мокінгу (mocking tools) у здатності мокати класи, через що ми отримуємо безліч беззмістовних інтерфейсів, які не несуть значення і існують лише для того, щоб дозволити мокінг. Тести, замість перевірки логіки, зосереджені на мокінгу інших класів, а найгірше — ці тести закріплюють конкретну реалізацію, перевіряючи, чи виклик коду пройшов із правильними параметрами у правильний час для змоканих залежностей.

Отже:

  • Ви маєте "подвійне кодування обліку": кожен раз, коли робите невелику зміну, необхідно змінювати і тест, і реальний код.
  • Ви також швидко досягаєте точки "окістеніння, спричиненого тестуванням" (test-driven ossification), бо більше не можете рефакторити свій код через побоювання, що доведеться переписувати і тести.

pic

Яка ж цінність усього цього? Коли нас знайомили з unit-тестуванням, обіцяли, що цінність тестів полягатиме в можливості змінювати код і виявляти регресії, запускаючи тести. Але нічого з цього і близько не відповідає дійсності! Кожного разу, коли ми змінюємо код, ми ламаємо БАГАТО тестів, які просто вказують на те, що тести були орієнтовані на конкретну реалізацію, і рідко коли на те, що ми випадково ввели регресію.

Слід сказати неприємну правду: усі ці тести приносять дуже мало користі, але мають високу ціну:

  • Низька цінність: оскільки всі ваші залежності змокані (mocked), ваші тести не доводять нічого, окрім того, що код робить те, що ви реалізували в класі. Що ж саме ви тестуєте? Чи працює компілятор? Яка цінність від тестування класу-контролера за допомогою мокінгу (mocking) шару сервісів?
  • Висока ціна: оновлення до нової основної версії бібліотеки ламає всі тести, оскільки тепер потрібно знову змокати все з цієї бібліотеки. Так само рефакторинг вашого коду ламає всі тести.

В результаті ви отримуєте купу нібито добре протестованого коду, якого ніхто не ризикує торкатися.

Ваші юніт-тести лише підтверджують, що код робить те, що ви задумали. Але вони не доводять, що код робить те, що повинен робити.

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

Хочу кращий ROI для своїх тестів

У монолітах ми пишемо юніт-тести для класів або функцій, адже це єдина "одиниця", яку можна собі дозволити запускати на пристрої розробника чи в CI/CD-пайплайні.

Але це зовсім не так для мікросервісів (micro-services). Одиницею тестування має бути сервіс, а не код, і ми можемо скомпілювати, розгорнути та запустити нову збірку за кілька секунд на звичайному ноутбуці середнього рівня. Чому ж ми цього не робимо?

Спробуйте розглядати сервіс як "одиницю", яку потрібно тестувати (або "систему, що тестується"). Ваш сервіс має обробляти API-запит, що створює новий об’єкт Stuff? Зробіть цей API-запит і перевірте, чи він повертає очікуваний результат. Додатково викличте іншу кінцеву точку ("get"), щоб переконатися, що об’єкт збережено належним чином. Ваш сервіс отримує повідомлення Kafka для оновлення об’єкта Stuff у базі даних? Надішліть це повідомлення вашому сервісу, дочекайтеся його обробки і викличте кінцеву точку "get", щоб перевірити, чи об’єкт Stuff оновлено відповідно до очікувань.

Наскільки можливо, сприймайте базу даних як деталь реалізації. У ідеальному світі ви могли б змінити базову технологію бази даних, не змінюючи жодного тесту. Теоретично ви могли б навіть писати тести іншою мовою програмування, відмінною від решти сервісу, і це не повинно було б мати значення.

Іншими словами:

Припиніть тестувати ваш код! Тестуйте ваш сервіс замість цього!

Звісно, ми не живемо в ідеальному світі, і завжди буде потреба в “старих добрих” юніт-тестах на рівні класів чи функцій, а також час від часу доведеться працювати з базою даних під час виконання тестів. Але якщо більшість вашого охоплення складається з тестів “юніт-як-сервіс”, ви досягаєте чудового результату:

  • Ви можете без страху рефакторити свій код: тести не мають змінитися, і на відміну від “юнiт-тестів на основі моків” (mock-based unit tests), вони дійсно підтверджують, що сервіс все ще виконує те, що повинен, оскільки вони тестують що робить сервіс, а не як він це робить;
  • Ви можете оновлювати залежності, включаючи основну версію бази даних, операційну систему, в якій працює додаток, середовище виконання Go чи Node.JS, або будь-що інше: адже всі ці елементи є частиною вашого тестування, і якщо тести “зелені” (успішні), то після розгортання навряд чи щось зламається;
  • Ви почнете усвідомлювати, що розробка, орієнтована на тестування (Test-Driven-Development), більше не здається неможливим, відірваним від реальності теоретичним концептом: скоро ви почнете думати про тести ще при прочитанні нового юзер сторі (user story) від власника продукту (PO), задовго до написання коду. А якщо захочете, можете навіть спробувати писати тест перед самим кодом, “по-справжньому”.

Заперечення!

pic

Ваша честь! У цій статті замало мемів!

Мені вперше показали цей підхід до тестування мікросервісів кілька років тому. Моя початкова реакція була: “це ніколи не спрацює”. Ось список заперечень, які я мав, і як вони показали себе на практиці:

Тести будуть занадто повільними

Важкі залежності, як правило, вже працюють до початку тестування: ваші сервери бази даних (DB), Kafka, SQS тощо запускаються як Docker-контейнери, які ви одного разу запустили через docker compose. Це зазвичай не створює проблем.

Запускайте їх вранці та зупиняйте ввечері. Під час CI/CD ці сервіси зазвичай запускаються досить швидко через Buddy/Jenkins або будь-яку іншу систему, і це потрібно робити лише раз на одне виконання конвеєра (pipeline).

Ці тести, звісно, працюватимуть трохи повільніше, ніж “чисті” тести на основі моків (mock-based tests):

  • Ви повинні запускати тести послідовно, а не паралельно, адже вони всі звертаються до бази даних (DB) тощо;
  • Під час налаштування тестового набору потрібно запускати основний HTTP сервер, прослуховувачі Kafka (Kafka listeners) тощо (тобто в beforeEach у Jest). Хоча це займає всього декілька секунд, все ж це трохи більше часу, ніж тести на основі моків.

На практиці це ніколи не виглядало як проблема: запуск одного тесту з IDE займає 4-5 секунд і повідомляє мені, що мій код дійсно працює “по-справжньому”. Пам’ятайте, цей єдиний тест перевіряє весь ланцюжок викликів від мого HTTP стеку до контролера, сервісу та логіки бази даних тощо. У підході з моками це були б кілька окремих тестів. Я можу почекати 4 секунди для цього!

Коли запускається повний набір тестів, різниця майже непомітна, оскільки вся ініціалізація виконується лише один раз на набір, а не для кожного тесту окремо.

Це занадто трудомістко

Так і ні. Це вимагає більше зусиль на початку: потрібно з самого початку налаштувати правильний тестовий фреймворк у вашому додатку. Вам потрібно мати доступ до правильних тестових утиліт, як, наприклад, “дочекайтесь повного споживання цього повідомлення Kafka”. Ці утиліти повинні бути розроблені у загальних бібліотеках, щоб вам не доводилося їх переписувати щоразу. Що стосується решти, це стає звичкою: спочатку процес повільний, але згодом входить у вашу пам'ять рухів.

Коли всі ці початкові зусилля залишилися позаду, це потребує значно менше зусиль: ви можете зосередитись на своєму коді, змінювати методи, структури класів або що завгодно стільки разів, скільки потрібно, не ламаючи ваші тести. Ви легко можете відтворити помилки в полі, написавши новий тест (тобто...“клієнт викликав API з такими параметрами” — ось приклади сценаріїв. Ви можете реалізовувати нові функції, що зачіпають існуючий код, без страху зламати щось. Ви легко зможете підтримувати свої залежності актуальними тощо. Ви знову почнете отримувати задоволення від написання і підтримки коду!

Тести будуть нестабільними і випадково помилятимуться

Ні, але так, але все ж ні.

Спочатку здається, що це просто, бо немає причин, чому виклик API повинен постраждати від якихось умов змагання (race condition): це простий прямий ланцюжок викликів до бази даних (DB) і назад, і нічого не повинно йти не так.

Але потім з'являються всі асинхронні процеси, і доводиться писати тести для логіки Kafka або SQS (або обох!), і все раптом стає дуже складним:

  • Скільки часу потрібно чекати, поки повідомлення Kafka буде оброблене? На що взагалі потрібно чекати?
  • Як мені протестувати складну логіку, наприклад, коли повідомлення Kafka пересилається на SQS, після чого повідомлення SQS обробляється і це тригерить виклик webhook?
  • Деякі дані в базі з’явилися після попереднього тесту і зламали наступний тест!
  • Я отримую повідомлення, що були поставлені в чергу попереднім тестом, і це зламує наступний тест!

Мій типовий підхід такий:

  • Переконайтесь, що ваше середовище максимально чисте перед початком кожного тесту: очистіть базу даних (truncate всіх таблиць), очистіть черги, де можливо (можливо для SQS, складніше для Kafka) тощо.
  • Переконайтесь, що ви точно знаєте, що ваш сервіс повинен робити, і тестуйте саме це. Якщо ваш сервіс має отримати одне повідомлення Kafka і створити два повідомлення SQS, не завершуйте тест, поки обидва повідомлення SQS не будуть отримані.
  • Сконцентруйтесь на вимогах та інтерфейсах вашого сервісу.

У прикладі з релейним передаванням з Kafka в SQS, реальний тест має бути: “коли я отримую повідомлення Kafka, то має бути викликаний webhook”: “передача” через SQS є деталлю реалізації, якою ваш тест не повинен перейматися;
- Переконайтесь, що у вас налаштоване очищення для будь-якого ресурсу, який ви виділяєте: логіка налаштування вашого тестового набору запустить ваш HTTP сервер, який може підключитися до бази даних (DB). Переконайтеся, що у вас є функція або метод для відключення від бази даних, який викликається під час завершення тестового набору.

Часто можна почути, що тести слід писати з такою ж ретельністю та строгістю, як і решту коду. Це надзвичайно актуально у цьому випадку.

Ви не можете запустити всі залежності на своєму ноутбуці

Справді, і docker зможе допомогти вам лише до певного рівня. Якщо вашому сервісу потрібно викликати інший сервіс у вашій інфраструктурі, тоді у вас не буде вибору, окрім як створити мок (mock) для цього сервісу. Моя порада: мокайте його “як сервіс”, тобто запускайте мок HTTP сервер під час ініціалізації тесту. Іншими словами, не мокайте ваш стек HTTP клієнта сервісу: ви хочете протестувати і його теж!

Чи працює це на практиці?

Так, працює. Я бачив, як це працює з Java SpringBoot, з Go та в Node. Це не теоретична вигадка, це підхід, який реально працює для справжнього коду для реальних клієнтів.

Остаточні думки щодо контролю якості (QA) і інтеграційних тестів

Підхід до тестування, описаний вище, буде “сприйматися як” інтеграційні тести розробниками. Але це не так. Ми все ще тестуємо лише одну “одиницю” — сервіс — в повній ізоляції з усіма зовнішніми взаємодіями (іншими сервісами), які замокані.

Якщо повернутися до попереднього зауваження:

Ці нові “тести наступного покоління” лише підтверджують, що ваш сервіс робить те, що, як вам здається, він робить. Вони не підтверджують, що ваш сервіс робить те, що має робити.

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

Ця увага до кінцевої подорожі користувача, на противагу технічним деталям конкретного сервісу, класу чи функції, — саме тут вам може знадобитися команда спеціалістів з контролю якості (QA).

Leave a Reply

Your email address will not be published. Required fields are marked *