Недавно я познакомился с методом тестирования ПО под названием «Мутационное тестирование» и уже успел стать фанатом такого подхода к написанию тестов.
Сначала теория
Цель мутационного тестирования состоит в выявлении неэффективных и неполных тестов, то есть это по сути тестирование тестов.
Идея состоит в том, чтобы изменять небольшие случайные фрагменты исходного кода и наблюдать за реакцией тестов. Если после внесения изменений тесты всё равно пройдены, то такой набор тестов неэффективен или неполон.
Правило, по которому выполняется преобразование в исходном коде, например, подстановка true
вместо false
, называется мутатором (мутационным оператором). В качестве мутаторов используются также замены знаков арифметических операций и булевых операторов, обнуление и перестановка переменных местами, удаление ветвей кода и другие. Изменения, внесенные в исходный код называются мутациями. В результате приобретения мутаций, исходный код мутирует и становится мутантом. После выполнения тестирования, мутанты делятся на две категории:
- убитые (пойманные) — те, в которых были выявлены отклонения и хотя бы один тест провалился
- выжившие (сбежавшие) — те, которые смогли пройти тесты успешно
При автоматическом мутационном тестировании создаётся множество мутантов оригинального исходного кода, и для каждого из них запускаются наборы тестов.
Метрикой эффективности мутационных тестов является индикатор MSI (Mutation Score Indicator), отражающий отношение убитых мутантов к выжившим. Чем больше разница между MSI и процентом покрытия кода тестами, тем менее информативным критерием для оценки качества тестов является их процент покрытия.
Случается, что сочетания мутаторов вызывают взаимоисключающие мутации, и тогда говорят, что полученный мутант эквивалентен (исходной программе). Отчасти поэтому добиться MSI в 100% бывает невероятно сложно даже в небольших проектах.
Теперь практика
Я расскажу о фреймворке для автоматического мутационного тестирования под названием Stryker.
Чтобы подготовить проект, установим глобально пакет stryker-cli:
npm i -g stryker-cli
Далее установим и сохраним в dev-зависимости проекта пакеты stryker и stryker-api
npm i --save-dev stryker stryker-api
В качестве фреймворка автоматического тестирования я буду использовать Mocha, а в качестве библиотеки утверждений мне привычна Chai:
npm i --save-dev chai mocha@3.5.0
Выполним stryker init
, эта утилита инициализации задаст несколько вопросов, я выбрал все согласно своим предпочтениям и конфигурации, плюс добавил в список отчетов пункт html. Это равноценно такой строчке:
npm i --save-dev stryker-api stryker-mocha-runner stryker-mocha-framework stryker-html-reporter
По окончании конфигурирования будет создан файл stryker.conf.js
примерно следующего содержания:
module.exports = function(config) {
config.set({
files: [{
pattern: 'src/**/*.js',
mutated: true,
included: false
},
'test/**/*.js'
],
mutate: [],
testRunner: 'mocha',
testFramework: 'mocha',
mutator: 'es5',
transpilers: [],
reporter: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest'
});
};
Разберемся в опциях и настроим его под себя:
files
— массив имен и шаблонов имен для указания файлов, нужных для тестирования. В качестве элементов можно использовать:
- строковые литералы, например,
'src/**/*.js'
. - InputFileDescriptor-объекты:
{ pattern: '', included: true, mutated: false }
, где
pattern
— обязательное поле с именем или шаблоном имени, но которое не поддерживает исключение файлов через!
в отличие от строковых литералов. То есть если файл или директория начинаются с!
и нужны в проекте, то используйте этот способ вместо строкового литерала.included
— необязательное поле, определяющее должен ли файл быть загружен в тест-раннер (true
) или просто скопирован в песочницу (false
). Во время выполнения можно наблюдать, как в структуре проекта мелькнула директория.stryker-tmp
, а в ней песочницы с мутантами, если проект зависит от вашего другого модуля, его надо тоже указать для копирования в песочницу.mutated
— необязательное поле, определяющее должен ли файл быть подвержен мутациям.
- строковые литералы, например,
mutate
— необязательный массив имен и шаблонов имен для указания файлов, которые должны мутировать. Можно обойтись без этого массива, если использовать InputFileDescriptor-объекты при выборе файлов в массивеfiles
.testRunner
— обязательное поле, указывает тест-раннер для тестов. Убедитесь в том, что установлен соответствующий плагин для Stryker, напримерstryker-karma-runner
для использованияkarma
в качестве тест-раннера.testFramework
— указывает фреймворк, используемый тестами. По умолчанию использует значение изtestRunner
mutator
— необязательное поле, указывает плагин-набор мутаторов, используемых при тестировании, по умолчаниюes5
.transpilers
— необязательное поле-массив, указывает транспиляторы, которые должны выполнить преобразования кода до начала выполнения.reporter
— необязательное поле-массив, с помощью которого можно выбирать формат представления отчетов после автоматических мутационных тестов.maxConcurrentTestRunners
— необязательное поле, определяющее количество одновременно выполняемых тестов.
В качестве ёмкого практического примера я создал проект со следующей структурой
+-- app.js
+-- package.json
+-- stryker.conf.js
L-- test
L-- app.test.js
главный файл содержит и экспортирует лишь одну функцию
// app.js
module.exports = {
userIsOldEnough: (user) => user.age >= 18
};
для обоснования концепции мутационного тестирования я снабжу проект юнит-тестами со 100% покрытием, даже в 2 прохода:
// test/app.test.js
const
expect = require('chai').expect,
app = require('../app');
describe('Site', () => {
it('can be visited by an adult', () => {
expect(app.userIsOldEnough({ age: 23 })).to.be.true;
});
it('can not be visited by a child', () => {
expect(app.userIsOldEnough({ age: 13 })).to.be.false;
});
});
конфигурационный файл Stryker выглядит так
// stryker.conf.js
module.exports = function(config) {
config.set({
files: [{
pattern: 'app.js',
mutated: true
},
'test/**/*.js'
],
testRunner: 'mocha',
reporter: ['html', 'clear-text', 'progress'],
testFramework: 'mocha'
});
};
я также добавил пару скриптов в package.json
для удобства:
{
"name": "mutations-demo",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "istanbul cover _mocha",
"posttest": "stryker run"
},
"main": "app.js",
"devDependencies": {
"chai": "^4.1.2",
"mocha": "^3.5.0",
"istanbul": "^0.4.5",
"stryker": "^0.13.0",
"stryker-api": "^0.11.0",
"stryker-html-reporter": "^0.10.1",
"stryker-mocha-framework": "^0.6.1",
"stryker-mocha-runner": "^0.9.1"
},
"dependencies": {
"underscore": "^1.8.3"
}
}
Выполним
npm t
и теперь начинается самое интересное: можно убедиться, что все юнит-тесты пройдены и они покрывают 100% кода
Site
? can be visited by an adult
? can not be visited by a child
2 passing (15ms)
=============================== Coverage summary ===============================
Statements : 100% ( 2/2 )
Branches : 100% ( 0/0 )
Functions : 100% ( 0/0 )
Lines : 100% ( 2/2 )
================================================================================
далее автоматически начинается мутационное тестирование, и вот тут мы получаем нехорошие новости в виде MSI 50%:
Mutant survived!
Mutator: BinaryOperator
- userIsOldEnough: (user) => user.age >= 18
+ userIsOldEnough: (user) => user.age > 18
Tests ran:
Site can be visited by an adult
Site can not be visited by a child
Ran 1.50 tests per mutant on average.
----------|---------|----------|-----------|------------|----------|---------|
File | % score | # killed | # timeout | # survived | # no cov | # error |
----------|---------|----------|-----------|------------|----------|---------|
All files | 50.00 | 1 | 0 | 1 | 0 | 0 |
app.js | 50.00 | 1 | 0 | 1 | 0 | 0 |
----------|---------|----------|-----------|------------|----------|---------|
Из отчета следует вывод, что тесты неполны, так как на их прохождение не повлияло изменение логческой операции с >=
на >
и следовательно, они не проверяют работу функции на случай, если пользователю сайта 18 лет ровно. Этот отчет выглядит как дифф между коммитами, но согласно настройкам сгенерируется и более красивый, в виде подобного html-документа.
Репозиторий с этим проектом лежит на Github. А чтобы можно было ничего не поднимать и просто поглядеть логи, я добавил проект в Travis.
Комментарии (4)
Aquahawk
27.10.2017 17:04и отдельно вопрос, а как он понимает какой код вообще стоит мутировать, он анализирует код теста и ищет что из него вызывается?
taxnuke Автор
27.10.2017 19:01Так это ж дело нехитрое, как говорится, «ломать — не строить»: мутаторы применяются бездумно, где только возможно. И соответственно, если есть какая-то новая часть кода, которая вообще не упоминается в тестах и даже не вызывается, то падают и покрытие, и MSI, причем последнее заметно сильнее, потому что к вообще не тестированной функции можно применить целое множество мутаторов и получить при этом пройденные тесты
vird
29.10.2017 22:34+1Очень интересная штука. Решил попробовать на своих модулях.
В некоторых модулях у меня 100% coverage, в некоторых 90+% а дальше лень.
Результаты для меня.
Полезные
1. Индексы. Мутация arr[idx + 1] в arr[idx — 1] увидела, что нет теста с 2 элементов.
2. Неиспользуемые опции. opt = {use_a:true} заменило на opt = {use_a:false} и поведение не поменялось. Хотя use_a не выбросило бы как warning т.к. переменная использовалась, но не в тех случаях которые в тестах.
Бесполезные
1. coffee компилирует for hki in [0… max_hki] в что-то такого вида (пример выжившей мутации):
- for (hki = _w = 0; 0 <= max_hki ? _w < max_hki : _w > max_hki; hki = 0 <= max_hki ? ++_w : --_w) { + for (hki = _w = 0; 0 <= max_hki ? _w < max_hki : _w >= max_hki; hki = 0 <= max_hki ? ++_w : --_w) {
Ну, можно поставить by 1, но идеологически ошибки нету, а в мутации есть и это не дает никакой пользы.
2. Я нигде не проверял текст exception'а. Потому все мутации в формировании имени exception'а выжили.
Ну серьезно. Мне не важно с какой ошибкой оно падает, главное, что падает. Т.е. ок, я поправлю тесты, внесу туда текст exception'а, но это не улучшит проект.
С чем я столкнулся при работе.
Проблема 1. Не поддерживается coffee.
Решение. Скоприровать и пройтись iced -c по всем файлам, а coffee удалить
Проблема 2. Оно очень плохо работает с асинхронными тестами с коробки
Решение. timeoutFactor: 3 иногда timeoutFactor: 10
Проблема 3. Некоторые мутации опасны для host машины.
Пример. Для тестов нужно записать в fs а мутация меняет папку.
Пример. Для тестов нужно очистить какую-то директорию через rm -rf.
Пример. Мутация вызывает бесконечный цикл меняя условие if (can_be true){break} на if (false){break} в while(true)
Решение отсекаем по timeout
Пример. Мутация вызывает бесконечное выделение памяти. После чего падает child у runner'а, после чего падает основной runner не выдавая отчёта.
Решение отсекаем по небольшому timeout дабы не успело выделить всю память.
Некоторые проблемы требуют взаимоисключающих решений. В одном случае увеличить timeoutFactor в другом наоборот оставить как есть, а иногда и уменьшить.
Проблема 4. Если тесты больше 30 сек.
Проблема 5. Если количество мутантов больше 1000.
Решение. Берём большой сервак на много ядер.
Но в пределе оно не умеет масштабироваться на несколько серверов.
С другой стороны запускать stryker на каждом чихе CI не самая лучшая идея. Это больше как еженедельная профилактика. Как статический анализатор.
Проблема 6. Была реальная ошибка, которая заключалась в том, что не передали последний параметр в функцию. Такого мутатора по умолчанию нет. Coverage не увидел.
Решение 1. Typescript. Но не поможет если параметр опциональный.
Решение 2. Написать мутатор самому. Еще не написал.
Проблема 7. Оно требует mocha^2.3.3, а уже есть 4-я.
Я забил.
Проблема 8. Кажется глобальный stryker и в node_modules ведут себя по-разному. Глобальный игнорирует опции в stryker.conf.js.
Но это неточно. И надо разбираться. Потому issue не постил.
Вердикт. Интересно, попробовал, все-равно интересно. Пока есть некоторая хрупкость. Выделю сбойные случаи — напишу им issue.
Мало мутаторов. Плохо работает в случае серъезных сбоев. Не запускать с проектами которые работают с fs на машинке с полезными данными или подключёнными сетевыми шарами!!!
Буду ли я использовать его для своих проектов? Пока нет. Только как профилактику раз в месяц.
Aquahawk
всё никак не доберусь до того чтобы попробовать мутационное тестирование на пет проекте. К сожалению в работе проекты большие, тестов много, выполняются долго(минуты). Хотя можно подумать и о том чтобы ночами гонять тесты по одному и выстроить очередь тестов по времени последнего изменения. Заниматься надо, а пока других задач не в проворот