[Из песочницы] Stryker, мутационное тестирование в JavaScript

habr.png

Недавно я познакомился с методом тестирования ПО под названием «Мутационное тестирование» и уже успел стать фанатом такого подхода к написанию тестов.


Сначала теория


Цель мутационного тестирования состоит в выявлении неэффективных и неполных тестов, то есть это по сути тестирование тестов.


Идея состоит в том, чтобы изменять небольшие случайные фрагменты исходного кода и наблюдать за реакцией тестов. Если после внесения изменений тесты всё равно пройдены, то такой набор тестов неэффективен или неполон.


Правило, по которому выполняется преобразование в исходном коде, например, подстановка 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.

© Habrahabr.ru