В своїй практиці я зустрічав проєкти з різними підходами до тестування: деякі мали 99% покриттям юніт-тестами, а в інших автоматичне тестування було відсутнє взагалі. В цій статті я хочу звернути увагу на характерні проблеми з автоматичними тестами, що мені зустрічались, і як їх вирішити.
Кожен хороший програміст переймається якістю власного коду, тому писати тести - це частина нашої роботи. Далі мова піде саме про тести, які створюються програмістами під час написання коду і проблеми в цих тестах. Це стосується як системних тестів, так і інтеграційних та модульних.
Проблема №1. Тести залежать від випадкових даних.
Важливою властивістю тестів є їх контрольованість. Тести повинні повертати однаковий результат в незалежності від пори року та температури в приміщенні. Якщо ваші тести випадково зазнають невдачі, ви не можете на них покладатись. Є великий ризик, що такі тести будуть тимчасово відключені або зовсім видалені.
Я пам'ятаю випадок, коли деякі тести почали падати в перший день місяця. Програміст розраховував, що в місяці завжди буде як мінімум 30 днів. У лютому його припущення не справдилося.
Також згадується випадок, коли системні тести розраховували на наявність певних записів в базі даних. На великий подив, в цьому ж оточенні відбувалося ручне тестування програми, тому в один момент необхідні дані були видалені. І так як тест очікував знайти запис за унікальним ID, який генерувався автоматично, відновити дані було складно. Довелося виправляти декілька десятків тестів.
Що робити?
Якщо тести залежать від випадкових величин на вході, ви рано чи пізно отримаєте проблеми. Краще попередьте їх, замінивши всі випадкові дані константами. До випадкових даних належать:
- дата і час
- дані специфічні для оточення (змінні оточення, назва комп'ютера, символ розмежування каталогів і т.ін.)
- файлова система
- мережа і бази даних
Якщо ви звертаєтесь до зовнішніх залежностей напряму у коді, розгляньте можливість заміни їх на обгортки, які пізніше можна буде замінити стабами в автотестах. Наприклад, замість DateTime.Now
оголосіть власний інтерфейс з методом Now
, наприклад IDateTimeService.Now
. В тесті замініть його реалізацію на константу new DateTime(2021, 2, 27)
і отримаєте контрольовані вхідні дані. В реальному коді метод Now
буде викликати DateTime.Now
і нічого не зміниться.
Досвідчені програмісти бачать наперед, чи можливо протестувати їх код. Якщо при написанні тестів ви переймаєтесь тим, як ваш код буде тестуватись далі, це свідчить про те, що практика тестування стала вашим надійним помічником і ви правильно користуєтесь цим інструментом.
Якщо ви розраховуєте на наявність певних даних в зовнішніх, по відношенню до вашого коду, системах, розгляньте можливість створення цих даних перед початком тесту і видалення їх після його завершення.
Проблема №2. Тести дублюють логіку коду.
Припустимо, у нас є математична формула, яку ми намагаємося реалізувати в коді. Наприклад, обчислення площі трикутника за його сторонами:
public double CalculateAreaOfTriangle(double a, double b, double c)
{
var p = (a + b + c) / 2;
return Math.Sqrt(p * (p - a) * (p - b) * (p - c));
}
При її тестування інтуїтивним підходом буде повторити формулу в тесті, щоб не обчислювати площу самостійно в калькуляторі. Але це не вірний підхід, бо програміст може допустити помилку, яка буде продубльована в тесті, особливо якщо один програміст пише і код, і тест до нього. Крім того, використання складних алгоритмів в тестах ускладнює їх підтримку.
Кращим підходом буде розрахувати значення самостійно і використовувати в тесті константу. Також непоганим підходом може бути використання еталонної реалізації. Для прикладу вище, якщо ви маєте бібліотеку для роботи з геометрією, вона добре відома і протестована, то можна порівняти результати виконання функції CalculateAreaOfTriangle
з аналогічною функцією з цієї бібліотеки.
Варто додати, що такого роду помилку я зустрічав раз чи двічі. В більшості проєктів, які я бачив, тести реалізовані вірно: константи використовуються як на вході, так і при перевірці тверджень.
Проблема №3. Тести не запускаються автоматично.
Ця помилка теж не дуже поширена. Інколи буває, що програмісти тримають тести до коду, який вони написали, лише на своєму ПК, або тести знаходяться в репозиторії але їх необхідно запускати вручну. Або запуск тестів автоматизовано, але тригером є певна подія, ініційована людиною: наприклад, лідер команди QA натискає кнопку у веб-інтерфейсі.
Загальним правилом є автоматичний запуск тестів при зміні коду: якщо змінився модуль А, то повинні бути запущені всі тести, що тестують модуль А (модульні тести), або тестують модулі А, Б і В як єдине ціле (інтеграційні тести). Інколи цю процедуру спрощують і запускають всі тести при будь-яких змінах в коді. Також можливий варіант, коли певні тести запускаються періодично зі сталим інтервалом. Результати таких тестів у випадку невдачі повинні доноситись певним чином до автора змін (обов'язково) і до всіх зацікавлених осіб (за бажанням).
Але приховувати тести, або запускати їх вручну - поганий підхід. Відповідальна за це людина може відкласти це на декілька днів, а то й тижнів. Весь цей час невдалий тест буде чекати, щоб повідомити про знайдену помилку. А чим раніше помилку виявлено, тим легше її виправити.
Проблема №4. Тести залежать від інших тестів.
При розробці системних (end-to-end) тестів виникає бажання їх спростити, перевикориставши результати, отримані з попередніх тестів.
Наприклад, у вас є онлайн-магазин і ви бажаєте протестувати те, як працює додавання товару в кошик. Припустимо, додати можна тільки існуючий на сайті товар, будучи зареєстрованим користувачем. Отже, щоб провести тестування кошика, нам необхідно попередньо створити користувача і товар, увійти в систему і перейти на сторінку створеного товару. Тільки тут нам буде доступною кнопка, яка додає товар до кошику.
Якщо у вас вже є готові тести для створення користувача і для додавання товару, а також тест для логіну, то виникає жагуче бажання в тестах для кошику використати результат роботи тестів реєстрації користувача і додавання товару. В такому разі програміст створює скрипт, в якому тести запускаються послідовно:
- Реєстрація користувача
- Додавання товару
- Вхід на сайт
- Перехід на сторінку товару
- Додавання товару в кошик
Тобто, наш тест залежить від даних, створених в результаті роботи інших 4 тестів. І ще добре, коли в документації вказано, які саме дані потрібні нашому тесту і як вони мають бути створені.
Ви можете сказати, що це неправильно і так ніхто не робить! Адже, будь-яка помилка в попередніх тестах зробить неможливою перевірку кошику. Для прикладу, якщо не працює реєстрація, ми, насправді, не знаємо, чи можуть вже зареєстровані користувачі придбати щось в нашому магазині. Так, все вірно. Але я бачив такі тести досить часто в своїй практиці, в тому числі у всесвітньо відомих продуктах.
Програміст, що написав такий код, може вам зауважити, що це економить час прогону тестів, адже для кожного нового тесту можна перевикористати вже доступні дані і не створювати/видаляти користувачів і товари кожен раз. Якщо врахувати, що дані зберігаються в досить повільних базах даних, це дійсно може суттєво економити час.
Такий підхід може працювати, але не довго. Поки тестів мало і ними займається декілька людей, які добре контактують і слідкують за станом тестів, проблем може не виникати. Але, як тільки з'являються сотні тестів і їх підтримкою починають займатися декілька команд, ви гарантовано отримаєте безлад і затримки з постачанням продукту.
Що робити?
Намагайтеся не писати автоматичних тестів, що залежать один від одного. Такі неочевидні залежності можуть коштувати дорого в майбутньому, коли тестів стає багато. Натомість, ви можете підготувати необхідні тестові дані у вигляді скрипту і перед кожним прогоном розгортати нову базу даних. Після того, як тести відпрацювали, ці дані повинні видалятись. Сьогодні доступні технології віртуалізації, що спрощують цей процес. Запуск docker контейнерів займає секунди. Тобто, декілька секунд часу і ви маєте БД з підготовленими для тестів даними. Наслідком цього є те, що вам необхідно підтримувати цей скрипт. Він повинен стати частиною репозиторію і змінюватись разом зі зміною структури даних.
До того ж, незалежні тести можуть виконуватись паралельно і таким чином працювати навіть швидше, ніж тести, що виконуються один за одним в чіткій послідовності.
Проблема №5. Тести неповні.
При написанні модульних тестів, зазвичай, задаються вхідні дані і перевіряються вихідні. Якщо ми тестуємо складні класи, що мають безліч залежностей, не завжди очевидно, що є результатом виконання системи під тестом.
Уявіть, що у нас є метод, що видаляє файл. Він перевіряє права доступу, далі звертається до сервісу файлів і логує результат операції. Всі сервіси, використані в цьому методі, є зовнішніми залежностями, функціонал яких ми не будемо тестувати. В коді тесту вони будуть існувати у вигляді моків.
public void DeleteFile(string fileName)
{
_permissionService.CheckPermissions(Operation.DeleteFile);
_fileService.DeleteFile(fileName);
_logger.LogInformation($"File '{fileName}' was successfully deleted.");
}
Вхідними даними для цього методу будуть:
- параметр
fileName
- успішність виконання методу
CheckPermissions
- успішність виконання методу
DeleteFile
- успішність виконання методу
LogInformation
Вихідними даними для методу будуть:
- факт виклику методу
CheckPermissions
і назва операції, для якої перевіряються права - факт виклику методу
DeleteFile
та ім'я файлу, що видаляється - факт виклику методу
LogInformation
і рядок тексту, що логується - скільки разів викликались зазначені вище методи
- чи викликались будь які інші методи будь яких сервісів
Тобто, вищенаведений код залежить не тільки від вхідних параметрів, але також від успішності виконання всіх методів. При тестуванні ми повинні задати всі вхідні дані і перевірити всі вихідні. Тільки у такому разі наш тест буде повним.
Марк Сіманн у своїй статті Dependency rejection називає ці данні непрямий вхід і вихід (indirect input and output). Але це такі самі вхідні та вихідні дані, як і параметри методу та його результат. Не можна їх ігнорувати, інакше не можна гарантувати, що код працює як належить.
Я часто бачив, як навіть досвідчені колеги ігнорували і не перевіряли частину вихідних даних, або не переймалися над тим, щоб задати частину вхідних даних. Пояснень я чув декілька: "тест повинен тестувати лише одну річ" і "ми не перевіряємо те, що не важливо для логіки програми".
З першим твердженням я погоджуюсь, тестувати потрібно лише одну річ за раз. Але "одна річ" значить не один Assert
в кінці тесту. Одна річ - це один виклик тестуємого методу, або, іншими словами, один набір вхідних даних. Перевіряючи лише частинку вихідних даних, ми не гарантуємо коректність роботи методу для даного випадку.
Якщо програміст каже, що не тестує те, що не важливо, можна запитати, для чого він додав неважливий код взагалі? Часто програмісти ігнорують перевірку запису логів. Логи програми, можливо, не важливі для програміста зараз і їх навряд чи можна назвати частиною бізнес логіки. Але логи точно стають важливими, коли код не працює у користувача. Коли аналіз логів - єдиний спосіб знаходження дефекту, вони надзвичайно важливі.
Не варто плутати "неповні тести" і "неповне покриття тестами". Неповне покриття означає, що деякі ділянки коду не задіяні в тестах. Це може бути припустимим, якщо це якісь унікальні випадки, кількість можливих тестових сценаріїв завелика і ваша команда не прагне до 100 % покриття.
Що робити?
Зважаючи на все це, можна зробити висновок, що код тесту буде об'ємнішим і складнішим, ніж код методу, що тестується. Часто так і є, особливо, коли клас має багато зовнішніх залежностей. Але це ціна, яку ми платимо за використання механізму ін'єкції залежностей (DI). Ви можете спростити собі життя, використовуючи бібліотеки для моків і фейків, наприклад Moq або NSubstitude. Але інколи все ж дисципліни в програмістів не вистачає і тоді ми приходимо до наступної, ще більшої проблеми з тестами.
Проблема №6. Тести написані неохайно.
Під неохайністю я маю на увазі, що код тестів написаний нашвидкоруч, без належної уваги. Дехто вважає, що код тестів - це щось другорядне і не варто йому приділяти багато уваги. Я з цим не згоден. Тести - це те, що забезпечує якість вашого коду. Якщо вам не важлива якість, тоді так, тести - другорядна річ і не варто витрачати зусилля, підтримуючи їх в належній формі. Але якщо ви вирішили писати тести і якість продукту для вас має значення, то варто підтримувати їх в такому ж стані, як і основний код. Це значить, що тести мають легко читатися, не мають містити дубльованого коду і, взагалі, підхід повинен бути точно такий як і до основного коду вашого продукту.
Я бачив ситуації, коли погано організовані тести потрапили в руки нової людини і та, замість того щоб розібратись і покращити їх, просто закоментувала код (також це можна зробити завдяки атрибутам типу [Ignore]
). Далі такий тест може бути просто забутий і контроль якості буде ослаблений.
Що робити?
Не варто зневажливо ставитись до тестів. Якщо їх проблематично змінювати, проведіть рефакторинг, точно так, як ви б зробили в основному коді. Закладайте час на написання тестів (модульних чи інтеграційних) при плануванні задач. Якщо ваш керівник просить знехтувати тестами, щоб пришвидшити розробку, запитайте, чи готовий він пожертвувати якістю продукту і темпом розробки в майбутньому, адже код без тестів змінювати страшно.
Проблема №7. Тести відсутні.
Можливо, для вас це звучить, як щось неймовірне? Впевнений, що для більшості це так. Але й досі існують софтверні компанії, які ігнорують тестування, що проводиться програмістами. Зазвичай, вони мають відокремлений підрозділ тестувальників, і відповідальність за якість продукту покладається на нього.
В таких компаніях, зазвичай, пишуть неякісний код і продукт випускається з безліччю дефектів. Задачі по розробці нових функції багато разів кочують з робочого столу програміста до робочого столу QA і назад. Випуск продукту здійснюється максимум раз в рік, а відділи тестувальників і програмістів часто в стані конфлікту.
Інколи мови програмування або фреймворки не полегшують можливості модульного тестування. Наприклад, мови Delphi або C++ мають дуже обмежені можливості рефлексії, створення фейків, моків і стабів. В цих мовах створення тестів для складного коду з безліччю залежностей може бути вкрай витратним процесом, тому модульного тестування уникають.
Крім того, часто програмісти не тестують свій код в компаніях, де історично склалась така культура. Інколи такі компанії були засновані не програмістами, а, наприклад, бухгалтерами або інженерами-електронщиками.
Що робити?
Ми, як професіонали своєї справи, маємо переживати за результат своєї праці. Тестування забезпечує якість і надійність, тож не ігноруйте його. Не треба розраховувати, що ваш колега QA знайде всі дефекти. Такий підхід веде до неохайного коду і програміст з часом деградує.
Спонукайте вашого керівника до переходу на інші, сучасні мови програмування, попросіть його знайти час на впровадження фреймворків тестування, які б полегшили вам роботу. Якщо ваш начальник не реагує, зверніться безпосередньо до керівництва компанії. В сучасному світі продукт, що тестується поверхово, не має майбутнього і може призвести до репутаційних та матеріальних втрат компанією.
Ще немає коментарів