Теорія і практика про мутаційне тестування, або «Ви не вмієте писати тести»

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Привіт, колеги. Я — Владислав, Software Engineer у компанії AltexSoft. Моя основна спеціалізація — front-end, але одразу зазначу, що зміст статті, так само як і інструменти, що будуть використовуватись, є актуальними для розробників різних профілів.

Сьогодні хочу поділитися із вами своїм досвідом мутаційного тестування. Тож, доєднуйтесь!

Дисклеймер: це перша моя стаття і, можливо, остання, адже життя front-end розробників в аутсорсі не те щоб дуже насичене. Не буду прохати у публіки поблажливого ставлення до написаного, але й сподіваюсь не зустріти голослівного хейту.

Рішення написати статтю виникло після серії співбесід, у яких я брав участь як інтерв’юер. Навіть у досвідчених розробників рівня senior, яких я мав за честь співбесідувати, робились круглими очі на запитання про mutation testing.

Сподіваюсь, що після цієї статті у нашій спільноті побільшає обізнаних на цю тему людей.

Зовсім трошки про мій досвід: майже 6 років пишу front-end, 3 із них з використанням різних типів тестів, переважно unit-тестів. 1,5 року пишу ефективні unit-тести завдяки впровадженню практики мутаційного тестування.

За ці 1,5 року було створено понад 8000 ефективних unit-тестів для свого (і не тільки) коду.

Вимоги, цільова аудиторія, користь

Перш ніж заглибитись у статтю, я хочу зробити невеликий відступ, щоб донести кілька важливих тезисів.

Щоб почати ефективно використовувати практику mutation testing, ви вже маєте володіти навичкою написання звичайних unit-тестів та реалізувати її на реальних комерційних проєктах. В іншому випадку, почавши одразу з мутаційного тестування, ви можете залишитися незадоволеним складністю та рутинністю процесу.

Якщо проводити аналогію, то mutation testing — це умовний TypeScript, що є в усьому кращим за JavaScript, але при цьому кожен front-end розробник починає свій шлях саме із JS. Це є процес еволюційного покращення базової навички.

Практика mutation testing є технологічно нейтральною. Впровадити її можна в будь-якій частині ваших проєктів. Інструмент, що буде описаний в наступних розділах, підтримує широкий список популярних мов програмування, які використовуються у сучасних проєктах як на front-end, так і на back-end. Усюди, де можливе написання unit-тестів, можлива присутність mutation testing.

Після ознайомлення зі статтею ви отримаєте насамперед теоретичну базу, якої вже буде достатньо, якщо хтось поставить вам питання на співбесіді. Окрім цього, якщо ви знайдете в собі сили та натхнення для спроби використати поради зі статті, то я гарантую вам корисний та якісний результат, хоча й для цього доведеться пройти через відомі фази прийняття. Я та мої колеги проходили через них.

Спочатку було заперечення. Навіщо нам ця практика? Хіба наших тестів недостатньо? Навіщо писати їх ще більше? Коли ти вперше запускаєш перевірку ефективності — приходить гнів.

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

У когось може з’явитись невеличка депресія від розуміння того, який об’єм ще потрібно буде дописати у вашому наявному проєкті. І врешті наступає прийняття, і ось вже кожен новий тестовий файл наповнюється ефективними тестами.

На цьому моменті я прошу вас задуматись на хвильку, чи хочете ви покращити тестування своїх проєктів? Чи хочете ви мати стабільніший код, знизити ризики несподіваних помилок у кінцевих користувачів?

Якщо ваша відповідь «так» — запрошую до читання далі. В іншому випадку — я ставлюсь до вас із повним розумінням і бажаю всього найкращого.

Трохи теорії

Я дозволив собі запозичити визначення терміну mutation testing в українському сегменті «Вікіпедії»:

Mutation testing (мутаційні тестування) — це метод тестування програмного забезпечення, який включає невеликі зміни коду програми. Якщо набір тестів не в змозі виявити такі зміни, то він розглядається як недостатній.

Ці зміни називаються мутаціями та ґрунтуються на мутаційних операторах, які або імітують типові помилки програмістів (наприклад, використання неправильної операції або імені змінної), або вимагають створення корисних тестів.

Як на мене, визначення терміну достатньо вичерпне. Але я не можу відмовити собі у задоволенні спростити його. Тож, на мою думку:

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

Прихована загроза

Щодня розробники нашої спільноти пишуть мільйони рядків коду та десятки тисяч тестів до них, а проте, як було зазначено у вступній частині, мало хто практикує mutation testing навіть серед досвідчених спеціалістів.

Мені здається, що причиною цьому є відсутність ресурсів клієнтів та/або розробників на цей процес. Далі ви зможете побачити, що практикування мутаційного тестування вимагає чи не більше часу, ніж написання коду та базових тестів до нього. І невеликий спойлер — чимало часу необхідно навіть на перевірку, чи є ваші тести достатньо ефективними.

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

І якщо вже на початку проєкту ви розумієте, що орієнтовні терміни розробки перевищують кілька років — буде доречним розглянути впровадження даної практики на початкових етапах, щоб спростити собі майбутньому життя, не накопичуючи велику неконтрольовану кількість мутацій у коді.

Якщо говорити про умовні невеликі проєкти, то зазвичай з ними працюють небагато людей, а середня тривалість розробки не перевищує 1–2 роки. Після цього проєкт відправляється у «production» або ж закривається.

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

Повторюсь, це зовсім не означає, що на таких проєктах не треба практикувати mutation testing, але шкода від відсутності цієї практики буде менш болюча.

Інша справа, якщо у вас великий проєкт. Команди таких проєктів можуть складатися з десятків або сотень одних лише розробників. Умовний лічильник кількості рядків коду вже давно перейшов межу в 100 000 і одному лише Ктулху відомо, скільки ще планується написати.

Цілком нормальна справа, коли за весь період розробки команда оновлює свій склад кілька разів і навіть ключові фігури не є виключенням. У такому «впорядкованому» хаосі майже зі 100% ймовірністю відбувається наступне:

  • Клієнт / product owner / інший менеджер регулярно просить вносити незначні незаплановані зміни.
  • Недосвідчений та/або новий колега в межах своєї задачі вносить зміни до наявного коду і не враховує якісь особливості бізнес-логіки або просто домовленості серед технічної команди тощо.
  • Досвідчені колеги припускаються логічної хиби «звернення до авторитету».
  • Регулярний рефакторинг застарілих складових проєкту.

Ймовірно, декілька пунктів або навіть усі відбуваються одночасно. Хочете ви того чи ні, але у вашому проєкті на постійній основі будуть створюватись незначні малопомітні мутації.

Частину з них відловлять ваші класичні unit-тести. Ще частина відпаде на етапі перевірки коду колегами. А проте, може, і незначний відсоток, але буде проходити через стандартні бар’єри та зрештою призведе до того, що в якийсь момент на вашому «проді» щось буде не так, як того очікує кінцевий користувач.

Мій досвід підказує, що об’єм мутацій завжди в кілька або навіть кільканадцять разів більший за розмір вашого ключового коду (без урахування верстки, стилів, сотень рядків імпортів тощо, лише логіка). Ба більше, зростання кількості мутацій не можна вважати рівномірним.

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

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

З очевидних причин я не можу розкривати деталі свого комерційного проєкту, але в один, безумовно, похмурий день наша команда виявила, що навіть за наявності тисяч тестів ми маємо майже 10 тисяч пропущених потенційних мутацій у front-end частині нашого проєкту.

І це при тому, що команда професійна та підтримує актуальні практики програмування. Годі й згадувати про низькоякісні проєкти (деяких недобросовісних індійських розробників), з якими стикався чи не кожен досвідчений розробник.

Інструментарій

Інструментів, що дозволяють практикувати mutation testing багато, але один із них не просто якісно виділяється, він відірвався від конкурентів так далеко, що після ознайомлення з ним вже ніколи не хочеться випускати його із рук.

Зветься це чудо Stryker Mutator (далі — Stryker). Безумовно, команда нашого проєкту розглядала й інші бібліотеки, але дуже хотіли знайти такий інструмент, що працюватиме і на front-end, і на back-end. Stryker підтримує мутаційне тестування для проєктів, що написані із використанням JavaScript, TypeScript, C# (і не тільки), що повністю задовольняє наші вимоги та потреби.

Ознайомитись з повним списком підтримуваних мов можна за вказаним посиланням.

Нумо розбиратись, як використовувати цей інструмент і не втратити глузд при цьому. На офіційному сайті можна знайти інструкцію зі встановлення бібліотеки до front-end вашого проєкту. Безумовно, там присутні також і статті для інших технологій, треба лише скористатись відповідними розділами сайту.

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

Але є виключення. Якщо ваш проєкт має складну структуру із залежними підпроєктами у якості submodules, кожен з яких має власний package.json файл, абсолютно кожну компоненту Stryker рекомендую встановлювати із параметром «-W» (workspace).

Наприклад, в одному великому проєкті (NDA) другий етап виглядав ось так:

npm install @stryker-mutator/core --dev -W

npm install @stryker-mutator/typescript-checker --dev -W

npm install @stryker-mutator/karma-runner --dev -W

Перша команда є обов’язковою. Друга потрібна, оскільки front-end написаний на TypeScript. Остання команда потрібна лише тому, що на третьому етапі, коли Srtyker мав би сам встановити цю компоненту автоматично, він не зміг підставити параметр -W.

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

Індикатором успішного закінчення процесу встановлення буде створення у директорії вашого проєкту, поруч з package.json, файлу stryker.conf.json.

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

Почнемо з «mutate» — однієї з найважливіших речей тут, яку окрім іншого ще й доведеться часто редагувати в процесі роботи зі Stryker (чому — поясню трошки пізніше). Відповідно до свого проєкту вам потрібно визначити, які теки Stryker має дивитись, щоб своїми бітовими рученятами робити там мутації.

Також не менш важливо визначити список, куди Stryker не варто заходити. Якщо цього не зробити, то він принаймні пробуватиме мутувати різні .js та .ts файли, а також наші .spec файли з тестами. Тому рекомендую приділити цьому увагу.

Наприклад:

«mutate»: [
	«src/app/modules/**/*.ts»,
	«!src/app/modules/**/actions/*»,
	«!src/app/modules/**/models/**/*»,
	«!src/app/modules/**/index.ts»,
	«!src/app/modules/**/*.spec.ts»
  ]

/**/ — означає всі теки будь-якого рівня вкладеності, що містяться всередині, в цьому випадку — директорії «modules». У вашому проєкті структура, очікувано, може відрізнятись.

! — дозволяє позначити ті самі заборонені локації. В цьому випадку я не хочу, щоб Stryker робив мутації у файлах тестів, індексів, моделей та подій. В усіх цих файлах у мене, як, ймовірно, й у вас, відсутня логіка, а лише декларації, описові конструкції та перелік імпортів / експортів.

Наступна опція, на яку я рекомендую звернути увагу — це «concurrency». Вона визначає кількість логічних ядер (потоків), яку ви дозволяєте використовувати Stryker. Це число має ділитись на два. Якщо ваш процесор має 8 ядер та 16 потоків, то це означає, що максимально допустиме значення — це 16, а мінімально — 2.

Одна надзвичайно важлива річ — Stryker вимогливий до вашого заліза. На цьому моменті майже неможливо утриматись від жарту.

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

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

Чому так, я поясню у наступному розділі, а зараз наведу реальний приклад.

Мій робочий ноутбук має процесор Intel Core i7-11800H (8c/16t), а ноутбук мого колеги — Intel Core i5-11300h (4c/8t). Один і той же набір файлів у нас виконується з різницею до 5 разів.

Доволі очікувано, можна сказати, але варто зазначити, що я не дозволяю Stryker використовувати всі 16 потоків, а також не раджу цього і вам. Для цього є кілька причин.

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

І якщо, віддавши Stryker всі наявні ресурси, ви намагаєтесь або продовжуєте щось робити — можливі проблеми з кінцевим результатом. З того, що я помічав — дивні результати, коли у звіті ви побачите неефективність тесту, але насправді у момент перевірки Stryker просто не вистачило ресурсів, адже ресурси системи активно були залучені деінде.

Тож оптимальним значенням буде половина / три чверті від доступних ресурсів. У моєму випадку, це 8 або 12.

При описі «mutate» я зазначав, що ви будете часто редагувати його. Це, власне, і пов’язано з тим, наскільки довго виконується один єдиний запуск Stryker. Наприклад, один невеликий компонент розміром 200 рядків коду може містити понад 50 мутацій. Час одного запуску Stryker може становити 5–10 хвилин в залежності від вашої щедрості при виділенні ресурсів цьому інструменту.

Але очевидно, що навіть середній fron-end проєкт матиме 10+ модулів та 100+ компонентів. Кількість потенційних мутацій гарантовано буде вимірюватись тисячами, якщо не десятками тисяч. А час виконання, без перебільшення, десятками та сотнями годин.

Тому найефективнішим способом роботи із Stryker є редагування поля «mutate», де ви обираєте або директорію із кількома компонентами, або ж навіть один конкретний компонент та запускаєте Stryker лише для нього.

Саме так роблю і я. З очевидних мінусів такого підходу — потрібно слідкувати за станом файлу, щоб випадково не зробити commit, та завжди запускати Stryker для коректних цілей. Безумовно, існує принаймні один варіант оминути редагування конфігу — створення CI-скрипту. Але цю навичку я ще не розблокував. На цьому моменті можна залишити в’їдливий коментар про професіоналізм автора.

На жаль, і це не всі підводні камені Stryker. За звичайних умов роботи Stryker створює власну тимчасову теку для, очевидно, тимчасових файлів. Називається вона .stryker-tmp. Після закінчення процесу тестування ця тека видаляється автоматично.

Але якщо ви будете з будь-якої на те причини переривати процес роботи Stryker, то автоматичне видалення не відбудеться. І це може стати проблемою, адже зміст теки може сягати розміру у десятки гігабайтів. Причому цей об’єм файлів може створюватись усього за кілька годин, якщо запускати та переривати роботу Stryker для великої кількості мутацій.

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

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

Я розумію, що після ознайомлення з кількома попередніми абзацами може виникнути бажання не використовувати Stryker. Але я запевняю вас, що інші інструменти працюватимуть за аналогічним патерном, пропонуючи натомість набагато менше, ніж Stryker. І це саме той випадок, коли я буду радий помилитись. Тож якщо ви маєте інформацію про кращий аналог — напишіть про це у коментарях. Завчасно дякую і рушаймо далі!

«checkers» — параметр, що визначає додаткові плагіни, які можуть використовуватись Stryker під час роботи. Особисто у мене в більшості проєктів значення цього параметру — [«typescript»]. Назва говорить сама за себе — підтримка мутацій з урахуванням особливостей TypeScript (типізація, інтерфейси тощо).

Використання плагінів позитивно впливає на якість перевірки, але збільшує час запуску Stryker приблизно на 30%. Не використовувати typescript-плагін для власне typescript based проєкту можна, але тоді у звіті ви побачите ті мутації, які просто неможливо зробити у такому проєкті, адже код навіть не компілюється.

Іншими словами, не використовувати плагіни можна, але використовувати — корисно.

«timeoutMS» — необов’язковий параметр, який вартий уваги. За замовчуванням він має значення 5000 ms. Для чого він використовується?

На перевірку кожної мутації Stryker виділяє певну визначену кількість часу, яка вираховується за формулою: timeoutForTestRunMs = netTimeMs * timeoutFactor + timeoutMS + overheadMs. netTimeMs та overheadMs автоматично вираховуються під час першого запуску Stryker, їх не змінити вручну.

А от timeoutFactor та timeoutMS можна задати. Навіщо це робити? Якщо у вас недостатньо ресурсів системи або багато асинхронного коду, то Stryker, ймовірно, витратить на один цикл більше часу, ніж визначено, внаслідок підрахунків за вищезазначеною формулою.

Коли це відбудеться, то Stryker вважатиме, що поточний запуск потрапив у вічний цикл і перерве його, а ту частинку коду, після якої це відбулось, позначить як timeout.

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

На форумах радять встановити значення timeoutMs у розмірі 60000 ms. Насправді тут варто поекспериментувати та підібрати значення, що підходить вам.

Але якщо не хочеться цього робити — просто використайте рекомендоване користувачами форумів значення. Попри те, що показник на порядок більший за дефолтний, вплив на кінцевий час виконання запуску Stryker незначний — до 10% більше часу.

Тож ще раз: якщо у вас дуже багато тестів, чимало «асинхронщини» (наприклад, таймери, затримки тощо) або недостатньо системних ресурсів, то раджу змінювати число на більше. В іншому випадку можна не чіпати.

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

  1. Знайти всі файли, в яких можна робити мутації (згідно з конфігурацією «mutate»). Запустити всі тести перший раз, визначити ті, що стосуються файлів, які будуть змінюватись. Stryker вміє ігнорувати непотрібні тести при наступних запусках. Вирахувати максимальний час очікування результатів тестів за вищезазначеною формулою.
  2. Зробити одну мутацію, змінивши щось у конкретно взятому рядку коду.
  3. Перевірити наявність тестів для зміненого коду. Якщо відсутні — позначити як «no coverage» та почати новий цикл.
  4. Перевірити наявність помилок. Якщо помилка з’явилась — відмітити мутацію як «error» та почати новий цикл.
  5. Якщо очікування результату вимагає більше часу, ніж визначено під час першого запуску — вважати, що мутація призвела до вічного циклу, перервати її, позначити як «timeout» та почати новий цикл.
  6. Якщо помилка відсутня — запусти всі тести, що мають відношення до цього файлу. Посилаюсь на попередній розділ, у цьому і полягає причина того, що робота Stryker вимагає купу ресурсів та триває багато часу. Зрозумійте важливу річ — Stryker запускає ваші тести, але дуже багато разів. Тому якщо вам здається, що час одного запуску триває невиправдано довго, то у першу чергу треба звернути увагу на свої тести. Не будемо розглядати сферичних коней у вакуумі, але як приклад зазначу, що один запуск Stryker для front-end нашого комерційного проєкту виконує приблизно 50 мільйонів тестів.
  7. Перевірити, чи є хоча б один тест, який закінчився з негативним результатом (failed). Якщо є, то помітити мутацію як «killed». Якщо нема — позначити як «survived».
  8. Цикл закінчується і ... все починається знову.

Мутації, що позначені як «error», «timeout», «killed» — є позитивним результатом. Це означає, що якщо хтось зробить таку мутацію власноруч, то ваші наявні тести «впадуть» і ця зміна за нормальних умов аж ніяк не потрапить до «проду». Вам потрібно буде оновити тести або переглянути зроблені зміни.

Мутації, що позначені як «survived», і є тими самими змінами, які не покривають ваші тести і які мають реальний шанс потрапити до кінцевого користувача.

Очевидно, що мутації позначені як «no coverage» взагалі не мають тестів і вважаються ще гіршим варіантом, ніж «survived».

Із всього цього і вираховується Stryker score або «оцінка ефективності тестів». Якщо ви маєте 100 мутацій, 30 з яких має статус «error», 5 «timeout», 40 «killed», 20 «survived» та 5 «no_coverage», то у підсумку ваша оцінка — 80%.

Що вважати гарною оцінкою, а що ні, я пропоную вам вирішувати самим, але в нашій команді ми дотримуємося принципу Парето, намагаючись досягти загальної оцінки для всього проєкту у 80%. В окремо взятих компонентах може бути оцінка нижче, а десь сягати 100%.

І це саме той випадок, коли принцип працює, адже ті 20% непокритих мутацій, що залишаються, вимагають набагато більших зусиль та часу, ніж вже знищені 80%.

Практика

Для прикладів я вирішив брати шматки коду зі свого великого pet-проєкту. Нагадаю, що інтеграція Stryker до вашого проєкту — це 5–10 хвилин, тому буде більше користі, якщо всі охочі просто зроблять це для своїх власних проєктів.

Проєкт, приклади якого будуть представлені далі, має фінансову тематику та написаний з використанням наступних технологій на front-end: Angular, TypeScript, RxJS, NgRX, Jasmine + Karma, а також низки дрібніших бібліотек, що не важливі для контексту статті. Місцями код та тести до нього були трохи спрощені виключно для зручності написання статті. Впливу на результати такі зміни не мали.

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

Критично важливо зрозуміти, чи достатня ефективність цих тестів. Ви можете робити колесо з дерева, сталі, бавовни, але якщо воно квадратне — його ефективність однаково жахлива.

Оскільки застосунок на фінансову тему, то почнемо з частини, яка відповідає за додавання картки користувача до системи. Це дуже проста маленька компонента, яка все, що робить — відкриває діалогове вікно та збирає інформацію з користувача. Код методів, що відповідає за це, виглядає наступним чином.

public openAddCardPopup(): void {
 if (this.popupReference) {
   return;
 }


 this.popupReference = this.popupService.open(this.addCardPopupTemplate);
 this.popupReference.afterClosed().subscribe(() => {
   this.popupReference = null;
 });
}


public closeAddCardPopup(): void {
 if (!this.popupReference) {
   return;
 }
 this.popupReference.close();
}

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

Stryker повідомляє про загалом 7 мутацій, які треба покрити тестами.

Я певен, що у більшості розробників тести до цього прикладу виглядатимуть приблизно так:

describe(`when «openAddCardPopup» is called`, () => {
 it('should call «open» from «popupService»', () => {
   component.openAddCardPopup();


   expect(popupServiceMock.open).toHaveBeenCalledWith(undefined);
 });
});


describe(`when «closeAddCardPopup» is called`, () => {
 it('should call «close» from «popupReference»', () => {
   component.openAddCardPopup();
   component.closeAddCardPopup();


   expect(popupRefMock.close).toHaveBeenCalled();
 });
});

Цей набір тестів перевіряє відкриття та закриття діалогового вікна. Як ви думаєте, яка ефективність цих тестів? Stryker вважає, що лише 70%. Подивімось, а що власне не так, що було пропущено.

Приблизно таким звітом (окрім того, що я додав жовтим кольором) буде виглядати кожен завершений запуск Stryker. Можна побачити, що саме зробив Stryker, і спробувати це відтворити у нашому коді, запустивши тести вручну після цього.

І дійсно так! Якщо ми просто прибираємо «return» з першої умови або з якоїсь причини модифікуємо саму умову, то наші тести цього не побачать. Те саме стосується і третього випадку, де має скидатись посилання на діалогове вікно після його закриття. Додаємо тести, які покриватимуть ці 3 пропущені потенційні мутації.

describe(`when «openAddCardPopup» is called`, () => {
 it('should call «open» from «popupService»', () => {
   component.openAddCardPopup();


   expect(popupServiceMock.open).toHaveBeenCalledWith(undefined);
 });


 describe(`when popup is opened already`, () => {
   it(`should not call «open» from «popupService»`, () => {
     // Open popup for the first time
     component.openAddCardPopup();
     popupServiceMock.service.open.calls.reset();


     // Try to open popup again
     component.openAddCardPopup();


     expect(popupServiceMock.open).not.toHaveBeenCalled();
   });
 });
});


describe(`when «closeAddCardPopup» is called`, () => {
 it('should call «close» from «popupReference»', () => {
   component.openAddCardPopup();
   component.closeAddCardPopup();


   expect(popupRefMock.close).toHaveBeenCalled();
 });


 describe(`when popup is closed already`, () => {
   it('should not call «close» from «popupReference»', () => {
     component.openAddCardPopup();
     component.closeAddCardPopup();
     popupRefMock.close.calls.reset();


     component.closeAddCardPopup();


     expect(popupRefMock.close).not.toHaveBeenCalled();
   });
 });
});

Ми додали 2 тести, які перевіряють, що не має відбутись, якщо діалогове вікно вже відкрито або вже закрито. Аналогічні перевірки на те, чого не має бути, стосуються й інших частин коду. Я розумію, що це може здатись дещо надлишковим чи надуманим, але саме із цим набором тестів ефективність сягає 100%.

Це означає, що майже будь-яка зміна коду або призведе до проблем із компіляцією, або до «падіння» тестів, що і стане гарантією стабільності цього коду для всіх розробників у команді, навіть тих, хто цей код ніколи в очі не бачили.

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

Спробуйте порахувати кількість потенційних мутацій у цих кількох рядках коду.

@Input() allCards: IPaymentCard[] = [];
@Input() hasDebts = false;


public get isDeleteButtonDisabled(): boolean {
 return this.hasDebts && this.allCards.length === 0;
}

Обережно припускаю, що більша частина розробників напише усього два тести. Для випадку, коли get має повернути true та false. Наприклад, щось ось таке:

describe(`when «hasDebts» equals «true» and «allCards» has no cards`, () => {
 it(`should have «isDeleteButtonDisabled» equal to «true»`, () => {
   component.hasDebts = true;
   component.allCards = [];


   expect(component.isDeleteButtonDisabled).toBeTrue();
 });
});


describe(`when «hasDebts» equals «false» and «allCards» has 2 cards`, () => {
 it(`should have «isDeleteButtonDisabled» equal to «false»`, () => {
   component.hasDebts = false;
   component.allCards = [cardMock, cardMock];


   expect(component.isDeleteButtonDisabled).toBeFalse();
 });
});

До написання тестів Stryker вказував на 6 неврахованих мутацій. Після додавання цих двох тестів — залишається ще 3. Розгляньмо їх у деталях.

  1. Будь-хто може змінити значення за замовчуванням змінної «hasDebts» і цього не побачать наші тести.
  2. Якщо зміниться умова для блокування кнопки — знову наші тести не допоможуть.
  3. Ця мутація малоймовірна у реальному житті, але Stryker нам говорить, що ми не покриваємо негативні випадки для другого параметра, що призводить до того, що вираз «this.allCards.length === 0» можна просто прибрати взагалі або замінити на «true».

Маємо доволі простий вибір — видалити Stryker і удати, що нічого й не було, або ж покращити наші тести. Я обираю другий варіант. Для зручності написання тестів, коли ми маємо покрити більш як 2 варіанти, я звик використовувати практику test cases. Окремо дякую своєму тімліду за ці знання, що значно спростили написання тисяч тестів.

Тож покращені тести, що гарантуватимуть нам оцінку в 100% для цих кількох рядків коду, можуть виглядати наступним чином:

it(`should have «hasDebts» equal to «false»`, () => {
 expect(component.hasDebts).toBeFalse();
});


[
 {
   hasDebts: false,
   allCards: [],
   expected: false,
   allCardsDescription: 'has no cards',
 },
 {
   hasDebts: true,
   allCards: [],
   expected: true,
   allCardsDescription: 'has no cards',
 },
 {
   hasDebts: false,
   allCards: [cardMock],
   expected: false,
   allCardsDescription: 'has 1 card',
 },
 {
   hasDebts: true,
   allCards: [cardMock],
   expected: false,
   allCardsDescription: 'has 1 card',
 },
].forEach(testCase => {
 describe(`when «hasDebts» equals «${testCase.hasDebts}» and «allCards» ${testCase.allCardsDescription}`, () => {
   it(`should have «isDeleteButtonDisabled» equal to «${testCase.expected}»`, () => {
     component.hasDebts = testCase.hasDebts;
     component.allCards = testCase.allCards;


     expect(component.isDeleteButtonDisabled).toBe(testCase.expected);
   });
 });
});

Був доданий один тест для перевірки початкового значення «hasDebts» та 2 нових тестових випадки для геттера. Іноді потрібно створити test cases для всіх можливих випадків, іноді достатньо трохи більше половини. Універсальної формули я не виводив, кожен випадок розглядаю окремо.

Наступний приклад є ускладненою версією попереднього.

public get isBanksTrackingAllowed(): boolean {
 return this.settings && this.settings.tracking.banks !== BanksTracking.NoTracking;
}


public get isSpecialOffersAllowed(): boolean {
 return this.settings && this.settings.offers.specialOffersAllowed;
}


public get isFinanceReportsShareAllowed(): boolean {
 return this.settings && this.settings.reports.shareFinancialReportsAllowed;
}


public get showSensitiveDataNotification(): boolean {
 return (
   this.isBanksTrackingAllowed ||
   this.isSpecialOffersAllowed ||
   this.isFinanceReportsShareAllowed
 );
}

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

16 потенційних мутацій. Об’єм тестів для цього випадку перевищує 200 рядків, а кількість тестів для отримання максимального значення Stryker score становить 17. Такий об’єм тестів вставляти недоречно, та й вони будуть структурно схожі на попередні.

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

Тут все просто — мої тести не перевіряли те, яку саме подію створює той чи інший метод. Тобто якби хтось змінив подію на іншу, тести показали б, що подія створюється, а яка — невідомо.

Інший приклад — Effect-сервіс.

Тут, маю визнати, взагалі було написано кілька тестів-заглушок, але я обов’язково виправлюсь.

Будь-який код, де є хоча б крихта логіки, матиме потенційні мутації, але всі їх знайде Stryker.

Короткий підсумок

Мутації коду були, є і будуть. Писати лише звичайні unit-тести — неефективно. Писати всі тести — довго і дорого, але корисно. Методика боротьби з мутаціями називається mutation testing. Потужний інструмент для її впровадження — Stryker Mutator.

Будь-які питання з теми прошу залишати у коментарях. Швидкої відповіді не обіцяю, адже на мене чекають ще кілька тисяч мутацій, але за можливості — не проігнорую ваші звернення. Нехай проблеми та незгоди не роблять вам в житті погоди! Хай щастить і будьте здорові!

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Є гарний інструмент для java-проектів — pitest. Конфігурується в 5 рядків коду

Для Java є подібна штука, називається PIT або PITest.

Мені здається нерозкритим питання в яких випадках цей інструмент буде кращим за branch coverage. Треба простіші приклади, де фокус буде на assertion coverage

Я думаю, що якби ми створили діаграму Ейлера, то branch coverage був би всередині mutation coverage (testing), адже остання практика передбачає всі мутації в умовних конструкціях + інші можливі зміни.

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

Дякую за конструктивне зауваження.

Попередньо проконсультувався із колегою, який багато років пише на С++. Чогось настільки потужного як Stryker він не зустрічав. Але в процесі написання статті я зустрічав згадку про mull-project та MuCPP. Що із цього бодай працює, не кажучи про доступний функціонал, я не знаю.

Підписатись на коментарі