Тести для тестів
Покриття каже, які рядки виконались під час тестів, але не каже, чи помітив би тест їхню зміну. Тест, що викликає price() і перевіряє лише toBeInt(), дає 100% покриття й не ловить нічого. Мутаційне тестування перевіряє саме це: вносить у код одну зміну, наприклад >= на >, + на - чи return 0 на return 1, і отримує . На ньому проганяють тести. Якщо хоч один упав, мутанта . Якщо всі зелені, він , і на цьому рядку справжня помилка теж пройшла б непоміченою.
Частку вбитих рахує . У PHP для цього є два інструменти. Infection (infection/infection) працює окремою командою з PHPUnit, phpspec і Codeception, рахує MSI і Covered MSI й валить CI через --min-msi. У Pest 5 плагін pest-plugin-mutate приходить разом із самим Pest: vendor/bin/pest --mutate мутує класи, названі в mutates() або covers() у тестах, а у звіті тих, що вижили, називає untested.
Ціна висока: кожен мутант означає ще один прогін тестів, які покривають змінений рядок, а щоб знати, які саме, потрібен драйвер покриття, Xdebug або PCOV. Тому мутують домен і розрахунки зі швидкими unit-тестами, а не контролери й конфіг. У PHP Ukraine мутаційне тестування не запускають: pest-plugin-mutate лежить у vendor як залежність Pest 5, але в тестах немає mutates() чи covers(), а deploy/deploy.sh не викликає --mutate.
Одна функція, три набори тестів
Shipping::price() робить доставку безкоштовною від 1000 грн, а інакше рахує 60 грн плюс 10 за кілограм. Усі три набори зелені й виконують кожен рядок методу, тобто мають 100% покриття. Перемикайте вкладки й дивіться, скільки з пʼяти мутантів демо нижче кожен з них вбиває.
Запустіть і подивіться
-
1
рядки 3–11
Код, який перевіряємо. Межа 1000 грн тут найлегше місце для помилки на одиницю.
-
2
рядки 13–20
Пʼять мутантів, кожен з однією зміною. Infection і Pest створюють їх самі, проходячи по синтаксичному дереву коду.
-
3
рядки 22–26
Два тести. Обидві гілки shipping() виконуються, тобто покриття рядків 100%.
-
4
рядки 28–56
Крихітний раннер: для кожного мутанта шукає перший тест, що впав. Немає такого тесту, і мутант вижив.
-
5
рядки 58–61
Спершу тести мають бути зелені на справжньому коді, інакше мутації нічого не означають. Потім прогін: 4 з 5, MSI 80%.
-
6
рядки 63–67
Мутант, що вижив, підказав тест на межу. Після нього вбито всіх.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Як мутаційне тестування стає тягарем
Гонитва за 100% MSI
Частина мутантів поводиться так само, як оригінал: наприклад, зміна межі в циклі, який усе одно виходить раніше. Їх не вбити жодним тестом, і команда пише дивні тести або здається.
Натомість: ставте поріг, якого вже досягли, і піднімайте його: --min-msi в Infection, --min у Pest. Мутантів, що вижили, читайте по одному, а свідомо байдужий рядок позначайте коментарем // @pest-mutate-ignore у Pest або ignore для мутатора в конфігу Infection.
Увесь проєкт на кожен коміт
Тисяча класів, по десятку мутантів на клас, і на кожен прогін тестів. CI іде годинами, і мутаційне тестування вимикають.
Натомість: у Pest мутуйте лише класи з mutates() у тестах, які ви змінюєте. В Infection на pull request є --git-diff-lines: мутуються лише додані й змінені рядки. Повний прогін залиште на ніч.
Мутації коду без логіки
Мутанти в DTO, конфігах, міграціях і контролерах, що лише передають запит далі. Вбити їх можна тільки повільними feature-тестами, а знайдена «прогалина» нічого не важить.
Натомість: мутуйте домен і розрахунки: тарифи, знижки, права, парсинг. Там мутант, що вижив, справді означає непомічену помилку.
Чи варто запускати мутаційне тестування тут
- Мутатор
- Правило зміни коду. У Infection це, наприклад, GreaterThanOrEqualTo (>= на >), Plus (+ на -), IncrementInteger (0 на 1). У Pest ті самі ідеї звуться GreaterOrEqualToGreater, PlusToMinus, IncrementInteger.
- Мутант
- Копія коду з однією зміною від мутатора. Інструмент створює їх сотні й на кожному проганяє тести, що покривають змінений рядок.
- Вбитий мутант
- Хоча б один тест упав на мутанті. Тести помітили зміну, і це добре. Pest рахує таких мутантів як tested.
- Мутант
- що вижив
- MSI
- Mutation Score Indicator: відсоток мутантів, які тести вбили. В Infection таймаути й фатальні помилки мутанта теж рахуються вбитими, а Covered MSI рахує той самий відсоток лише серед мутантів у покритому коді.
Питання зі співбесід
Мутант це копія коду з однією навмисною зміною, наприклад >= замінено на >. На ньому запускають тести. Якщо хоч один упав, мутанта вбито: тести помітили зміну. Якщо всі зелені, мутант вижив, і цей рядок насправді не перевірено.
Покриття рахує виконані рядки, а не перевірені. Тест, що викликає метод і перевіряє лише тип результату, покриває все й не ловить жодної помилки в розрахунку. Мутаційне тестування міряє інше: чи впаде хоч один тест, якщо рядок змінити.
MSI рахує вбитих серед усіх мутантів, разом з тими, що в рядках, яких не виконує жоден тест. Covered MSI рахує лише серед мутантів у покритому коді, тобто показує якість наявних тестів без поправки на непокриті місця. Високий Covered MSI при низькому MSI означає: тести добрі, але їх замало.
Не на весь код одразу. Беруть модулі з логікою й швидкими unit-тестами, на pull request мутують лише змінені рядки (--git-diff-lines в Infection) або класи з mutates() у Pest, паралелять (--threads у Infection, --parallel у Pest), а повний прогін ставлять на ніч. Поріг --min-msi фіксують на досягнутому рівні й піднімають поступово, так само як рівень PHPStan.