Недавно я познакомился с методом тестирования ПО под названием «Мутационное тестирование» и уже успел стать фанатом такого подхода к написанию тестов.
Сначала теория
Цель мутационного тестирования состоит в выявлении неэффективных и неполных тестов, то есть это по сути тестирование тестов.
Идея состоит в том, чтобы изменять небольшие случайные фрагменты исходного кода и наблюдать за реакцией тестов. Если после внесения изменений тесты всё равно пройдены, то такой набор тестов неэффективен или неполон.
Правило, по которому выполняется преобразование в исходном коде, например, подстановка 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
└── test
└── 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.