Мутационный анализ, или как тестировать тесты

    Тестов много не бывает — это все знают. Мемы про unit и интеграционное тестирование уже не очень-то веселят. А мы по-прежнему не знаем, можно ли полагаться на результаты прохождения тестов, и какой процент покрытия позволит не пустить баги в продакшен. Если фатальные изменения в коде проскакивают тесты, не оказывая влияния на их результат, то решение напрашивается само — надо тестировать тесты!



    О подходе к автоматизации этой задачи и был доклад Марка Лангового на Frontend Conf. Видео и статья короткие, а идеи очень рабочие — надо брать на заметку.


    О спикере: Марк Ланговой (marklangovoi) работает в Яндексе в проекте Яндекс.Толока. Это краудсорсинговая площадка для быстрой разметки большого количества данных. Заказчики загружают данные, которые, например, нужно подготовить для использования в алгоритмах машинного обучения, и назначают цену, а другая сторона — исполнители могут выполнять задания и зарабатывать.

    В свободное от работы время Марк развивает Краснодарское сообщество разработчиков Krasnodar Dev Days — одно из 19 IT-сообществ, активистов которых мы пригласили на Frontend Conf в Москву.

    Тестирование


    Существуют разные виды автоматизированного тестирования.


    В ходе популярного модульного тестирования мы пишем тесты на маленькие части (модули) приложения. Они просты в написании, но иногда во время интеграции с другими модулями могут вести себя не совсем так, как мы ожидали.

    Чтобы избежать этого, мы можем писать интеграционные тесты, которые будут проверять работу наших модулей совместно.


    Они немного сложнее, поэтому сегодня мы остановимся на модульном тестировании.

    Модульное тестирование


    Любой проект, который хочет хоть какой-то минимальной стабильности, занимается написанием модульных тестов.

    Рассмотрим пример.

    class Signal {
        on(callback) { ... }
        off(callback) {
            const callbackIndex = this.listeners.indexOf(callback);
            if (callbackIndex === -1) {
                return;
            }
            this.listeners = [
                ...this.listeners.slice(0, callbackIndex - 1),
                ...this.listeners.slice(callbackIndex)
            ];
        }
        trigger() { ... }
    }
    

    Есть класс Signal — это Event Emitter, у которого есть метод on для подписки и метод off для удаления подписки — проверяем, если callback содержится в массиве подписчиков, то удаляем. И, конечно, есть метод trigger, который будет вызывать подписанные callback.

    У нас есть простой тест для этого примера, который вызывает методы on и off, а затем trigger, для того чтобы проверить, что callback не вызвался после отписки.

    test(’off method should remove listener', () => {
        const signal = new Signal();
        let wasCalled = false;
        const callback = () => {
            wasCalled = true;
        };
        signal.on(callback);
        signal.off(callback);
        signal.trigger();
        expect(wasCalled).toBeFalsy();
    });
    

    Критерии оценки качества


    Какие есть критерии оценки качество такого теста?

    Code coverage — самый популярный и всем известный критерий, который показывает, сколько процентов строк кода были исполнены при запуске теста.


    У вас может быть 70%, 80% или все 90% Code coverage, но значит ли это, что, когда вы соберете очередной билд для продакшена, все будет хорошо, или что-то может пойти не так?

    Вернемся к нашему примеру.

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

                ...this.listeners.slice(0, callbackIndex - 1),
                ...this.listeners.slice(callbackIndex)
    

    Ты решил, что наверное можно просто очищать массив:

    class Signal {
        ...
        off(callback) {
            const callbackIndex = this.listeners.indexOf(callback);
            if (callbackIndex === -1) {
                return;
            }
            this.listeners = [];
        }
        ...
    }
    

    Сделал коммит, собрал проект и отправил в продакшен. Тесты прошли — почему бы и нет? И пошел отдыхать в бар.



    Но вдруг поздно ночью звонок, в трубку кричат, что все падает, люди не могу пользоваться продуктом, и вообще — бизнес теряет деньги! Ты горишь, тебе грозит увольнение.



    Как с этим быть? Что делать с тестами? Как отлавливать такие примитивные глупые ошибки? Кто же будет тестировать тесты?

    Конечно, можно нанять армию QA-инженеров — пусть сидят и просто клацают наше приложение.



    Или нанять QA-автоматизаторов. На них можно свалить работу по написанию тестов — зачем писать самим, если для этого есть специальные люди?

    Но на самом деле это дорого, поэтому мы сегодня поговорим про мутационный анализ или мутационное тестирование.

    Мутационное тестирование


    Это способ автоматизировать процесс тестирования наших тестов. Его цель — выявление неэффективных и неполных тестов, то есть, по сути, это тестирование тестов.

    Идея состоит в том, чтобы изменять куски кода, запускать на них тесты, и, если тесты не упали, значит, они неполные.

    Изменения вносятся с помощью определенных операций — мутаторов. Они заменяют, например, плюс на минус, умножить на разделить, и прочие подобные операции. Мутаторы могут менять куски кода, заменять условия в while, обнулять массивы вместо добавления в массив какого-то элемента.


    В результате применения мутаций на исходный код, он мутирует и становится мутантом.

    Мутанты делятся на две категории:

    1. Убитые — те, в которых мы смогли выявить отклонения, то есть на которых хотя бы один тест упал.
    2. Выжившие — те самые, которые убежали от нас, и донесли баг до продакшена.

    Для оценки качества есть метрика MSI (Mutation Score Indicator) — процентное отношение между убитыми и выжившими мутантами. Чем больше разница между покрытием кода тестами и MSI, тем хуже отражает актуальность наших тестов процент покрытия кода.

    Это было немножко теории, а теперь рассмотрим, как это можно использоваться в JavaScript.

    Решение для JavaScript


    В JavaScript существует только один активно развивающийся инструмент мутационного тестирования — это Stryker. Такое название инструмент получил в честь персонажа X-man Уильяма Страйкера — создателя «Оружия X» и борца со всеми мутантами.



    Stryker не является test runner, как Karma или Jest; также он не является framework’ом для тестов, как Mocha или Jasmine. Это framework для мутационного тестирования, который дополняет вашу текущую инфраструктуру.

    Система плагинов


    Stryker очень гибкий, полностью построен на системе плагинов, большинство из которых написаны разработчиками Stryker’a.


    Существуют плагины для запуска тестов на Jest, Karma и Mocha. Есть интеграция с фреймворками Mocha (stryker-mocha-framework) Jasmine (stryker-jasmine) и готовые наборы мутаторов для JavaScript, TypeScript и даже для Vue:

    • stryker-javascript-mutator;
    • stryker-typescript;
    • stryker-vue-mutator.

    Мутаторы для React входят в stryker-javascript-mutator. Помимо этого, вы всегда можете написать свои мутаторы.

    Если код нужно преобразовать перед запуском, можно использовать плагины для Webpack, Babel или TypeScript.


    Настраивается это все относительно просто.

    Конфигурация


    Конфигурирование не составит большого труда: вам только нужно указать в JSON-конфиге, какой test runner (и/или test framework, и/или transpiler) вы используете, а также установить соответствующие плагины из npm.

    Простая консольная утилита stryker-cli может все это сделать за вас в режиме вопрос-ответ. Она спросит вас, что вы используете, и сформирует конфигурацию самостоятельно.

    Как это работает


    Жизненный цикл прост и состоит из следующих шагов:

    • Чтение и анализ конфига. Stryker загружает конфиг и анализирует его на различные плагины, настройки, исключение файлов и т.д.
    • Загрузка плагинов согласно конфигу.
    • Запуск тестов на исходном коде для того, чтобы проверить, актуальны ли сейчас тесты (вдруг они уже сломаны).
    • Если все хорошо, генерируется набор мутантов по файлам, которые мы разрешили мутировать.
    • Запуск тестов на мутантах.



    Выше пример запуска Stryker:

    • Stryker запускается;
    • считывает конфиг;
    • подгружает нужные зависимости;
    • находит файлы, которые будет мутировать;
    • запускает тесты на исходном коде;
    • создает 152 мутанта;
    • запускает тесты в 8 потоков (в данном случае на базе количества ядер CPU).

    Это все не быстрый процесс, поэтому лучше его делать на каких-нибудь CI/CD серверах.

    После прохождения всех тестов Stryker дает краткий отчет по файлам с количеством созданных, убитых и выживших мутантов, а также процент соотношения убитых мутантов к выжившим (MSI) и мутаторы, которые были применены.

    Это и есть потенциально возможные проблемы, которые не предусмотрели в наших тестах.

    Подытожим


    Мутационное тестирование полезно и интересно. Оно может найти проблемы на ранних стадиях тестирования, причем без участия людей. Оно сократит время на проверку pull request, например, за счет того, что квалифицированным разработчикам не нужно будет тратить время на проверку pull request, в котором уже есть потенциальные проблемы. Или спасет продакшен, если вы решите подготовить новый релиз в пятницу вечером.

    Stryker — гибкий многопоточный инструмент для мутационного тестирования. Он активно развивается, но пока сыроват, до сих пор не дошел до мажорной версии. Например, за период подготовки этого доклада, его разработчики наконец сделали в плагине для Babel возможность указать файл конфигурации и починили Jest-интеграцию. Это OpenSource проект, которому можно помочь развиваться.

    Вопросы-ответы
    — Как тестировать мутационные тесты? Наверняка, тоже есть погрешность. В первом примере с модульным тестированием было покрытие 90%. Казалось бы, все хорошо, но все равно проскакивали кейсы, когда все падало и было в огне. Соответственно, почему должно появиться ощущение того, что все хорошо, после покрытия еще этих тестов мутационными?

    — Я не говорю, что мутационное тестирование — это серебряная пуля и все вылечит. Естественно, могут быть какие-то пограничные безумные случаи или отсутствие какого-то мутатора. В первую очередь легко отлавливаются типичные ошибки. Например, ставишь проверку на возраст, поставил ее <18 (нужно было <=), а в тесте забыл сделать проверку пограничного случая. У тебя выполнилось другое сравнение мутатором, и в итоге тест упал (или не упал), и ты понимаешь, что все хорошо или все плохо. Такие вещи быстро отлавливаются. Это способ просто дописать тесты правильно, найти упущенные моменты.

    — Часто у тебя происходит ситуация «задеплоил и ушел»? Я считаю, что это неверно.

    — Нет, но я думаю, что в многих проектах подобные вещи все-таки существуют. Естественно, это неверно. Многие считают, что Code coverage помогает все проверить, можно спокойно уйти и не переживать — но это не так.

    — Сразу скажу, в чем проблема. У нас куча всяких редьюсеров и прочего, что мы мутационно тестируем, и их очень много. Это все разрастается, и получается, что на каждый pull request запускается мутационное тестирование, которое занимает много времени. Есть ли возможность запуска только на то, что изменилось?

    — Думаю, это можно настроить самому. Например, на стороне разработчика, когда он пушит, комитит, можно сделать lint-staged плагин, который будет прогонять только те файлы, которые изменились. На CI/CD тоже такое возможно. В нашем случае проект очень большой и старый, и мы практикуем точечную проверку. Мы не проверяем все, потому что это займет неделю, будут сотни тысяч мутаций. Я бы рекомендовал делать точечные проверки, либо самому организовывать выборочный процесс запуска. Готового инструмента для такой интеграции я не видел.

    — Обеспечивается ли полнота всех возможных мутаций для конкретного фрагмента кода? Если нет, то, как именно выбираются мутации?

    — Лично не проверял, но и проблем с этим не встречал. Stryker должен генерировать все возможные мутации на один и тот же фрагмент кода.

    — Хочу спросить по поводу snapshot’ов. У меня unit-тест тестирует и логику, и, в том числе, верстку snapshot react-компонента. Естественно, если я любую логическую конструкцию изменю, у меня тут же поменяется верстка. Это ожидаемое поведение, разве не так?

    — Да, в этом их смысл, что ты сам вручную snapshot’ы обновляешь.

    — То есть ты snapshot’ы как-то игнорируешь в этом репорте?

    — Скорее всего, snapshot’ы нужно заранее обновить, а потом запустить мутационное тестирование, иначе будет куча мусора от Stryker.

    — Вопрос по поводу CI-серверов. Для просто unit-тестов есть reporter’ы — под GitLab, под все, что угодно, которые выводят процент успешного прохождения тестов, и ты можешь настроить — фейлить или не фейлить. А что у Stryker? Он просто выводит табличку в консоль, но что дальше с ней делать?

    — У них есть HTML-reporter, можно сделать свои reporter’ы — все гибко настраивается. Возможно, есть какие-то конкретные инструменты, но так как мы пока занимаемся точечным мутационным тестированием, я не находил конкретных интеграций с TeamCity и подобными инструментами CI/CD.

    — Насколько мутационные тесты увеличивают поддержку вообще тестов, которые у тебя есть? То есть тесты — это боль, и тесты надо переписывать, когда код переписывается, и пр. Иногда проще код переписать, чем тесты. А тут я еще и мутационные тесты. Насколько это дорого для бизнеса?

    — Сначала я, наверное, поправлю, что переписывать код ради тестов — это неправильно. Код должен быть легко тестируемым. Насчет того, что нужно дописывать — это опять же для бизнеса важно, чтобы тесты были максимально полны и эффективны. Если они не полные, это значит, что может возникнуть баг, который принесет потери. Естественно, можно тестировать только самые важные для бизнеса части.

    — Все же — насколько становится дороже, когда появляются мутационные тесты, чем если бы их не было.

    — Это дороже настолько, насколько плохие тесты сейчас. Если сейчас тесты написаны плохо, то придется много дописывать. Мутационное тестирование будет находить случаи, которые не покрыты тестами.

    — На слайде с результатами проверки Stryker много ворнингов, они критические или не критические. Как обрабатывать ложные срабатывания?

    — Тонкий вопрос — что считать ложным. Я спрашивал ребят у нас в команде, что у них выпадало интересного из таких ошибок. Было пример насчет текста ошибки. Stryker выдал, что тесты не отреагировали на то, что текст ошибки изменился. Вроде бы, косяк, но минорный.

    — То есть вы такие ошибки видите и пропускаете некритичные в ручном режиме?

    — У нас точечная проверка, поэтому да.

    — У меня практический вопрос. Когда вы это внедрили, какой процент тестов у вас повалился?

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

    Другие, такие же полезные, выступления по фронтенду смотрите на нашем youtube-канале, туда постепенно попадают все тематические доклады со всех наших конференций. Или подпишитесь на рассылку, и мы будем держать вас в курсе всех новых материалов и новостей будущих конференций.
    • +35
    • 7,2k
    • 2

    Конференции Олега Бунина (Онтико)

    409,00

    Конференции Олега Бунина

    Поделиться публикацией

    Похожие публикации

    Комментарии 2
      +3
      Это вообще безопасно? Или такие тесты надо запускать лишь в сендбоксе, где-нибудь на необитаемом острове посреди океана? Не может-ли оно, при определенных условиях, намутировать такой код, что он подломает вам энвайронмент и устроит внеплановый зомби апокалипсис?
      Типа такого:
      db = (NODE_ENV === 'production') ? 'prod' : 'test'
      drop(db)
      

      Заменим условие в мутанте первого уровня на false, а я мутанте 80-го на true.
        +3

        Так тесты на то и тесты, чтобы запускать их в изолированных средах, разве нет?

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое