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