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


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


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


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


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


  1. Aquahawk
    27.10.2017 17:03

    всё никак не доберусь до того чтобы попробовать мутационное тестирование на пет проекте. К сожалению в работе проекты большие, тестов много, выполняются долго(минуты). Хотя можно подумать и о том чтобы ночами гонять тесты по одному и выстроить очередь тестов по времени последнего изменения. Заниматься надо, а пока других задач не в проворот


  1. Aquahawk
    27.10.2017 17:04

    и отдельно вопрос, а как он понимает какой код вообще стоит мутировать, он анализирует код теста и ищет что из него вызывается?


    1. taxnuke Автор
      27.10.2017 19:01

      Так это ж дело нехитрое, как говорится, «ломать — не строить»: мутаторы применяются бездумно, где только возможно. И соответственно, если есть какая-то новая часть кода, которая вообще не упоминается в тестах и даже не вызывается, то падают и покрытие, и MSI, причем последнее заметно сильнее, потому что к вообще не тестированной функции можно применить целое множество мутаторов и получить при этом пройденные тесты


  1. 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 на машинке с полезными данными или подключёнными сетевыми шарами!!!
    Буду ли я использовать его для своих проектов? Пока нет. Только как профилактику раз в месяц.