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

    Юнит тесты помогают нам удостовериться, что код работает так, как мы этого хотим. Одной из метрик тестов является процент покрытия строк кода (Line Code Coverage).


    Но насколько корректен данный показатель? Имеет ли он практический смысл и можем ли мы ему доверять? Ведь если мы удалим все assert строки из тестов, или просто заменим их на assertSame(1, 1), то по-прежнему будем иметь 100% Code Coverage, при этом тесты ровным счетом не будут тестировать ничего.


    Насколько вы уверены в своих тестах? Покрывают ли они все ветки выполнения ваших функций? Тестируют ли они вообще хоть что-нибудь?


    Ответ на этот вопрос даёт мутационное тестирование.


    Мутационное тестирование — это метод тестирования ПО, основанный на всевозможных изменениях исходного кода и проверке реакции на эти изменения набора автоматических тестов. Если тесты после изменения кода успешно выполняются, значит либо код не покрыт тестами, либо написанные тесты неэффективны. Критерий, определяющий эффективность набора автоматических тестов, называется Mutation Score Indicator (MSI).

    Введем некоторые понятия из теории мутационного тестирования:


    Для применения этой технологии у нас, очевидно, должен быть исходный код (source code), некоторый набор тестов (для простоты будем говорить о модульных — unit tests).


    После этого можно начинать изменять отдельные части исходного кода и смотреть, как реагируют на это тесты.


    Одно изменение исходного кода будем называть Мутацией (Mutation). Например, изменение бинарного оператора "+" на бинарный "-" является мутацией кода.


    Результатом мутации является Мутант (Mutant) — то есть это новый мутированный исходный код.


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


    Кроме изменения "+" на "-", существует множество других мутационных операторов (Mutation Operator, Mutator) — отрицание условий, изменение возвращаемого значения функции, удаление строк кода и т.д.


    Итак, мутационное тестирование создает множество мутантов из вашего кода, для каждого из них запускает тесты и проверяет, выполнились они успешно или нет. Если тесты упали — значит всё хорошо, они отреагировали на изменение в коде и поймали ошибку. Такой мутант считается убитым (Killed mutant). Если тесты выполнились успешно после мутирования — это говорит о том, что либо ваш код не покрыт в этом месте тестами вовсе, либо тесты, покрывающие мутированную строку, неэффективны и в недостаточной степени тестируют данный участок кода. Такой мутант называется выжившим (Survived, Escaped Mutant).


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


    Рассмотрим пример. Будем использовать мутационный фреймворк (МФ) для PHP — Infection.


    Пусть у нас есть какой-то фильтр, умеющий отфильтровывать коллекцию пользователей по признаку совершеннолетия, написанный в объектно-ориентированном стиле:


    class UserFilterAge
    {
        const AGE_THRESHOLD = 18;
    
        public function __invoke(array $collection)
        {
            return array_filter(
                $collection,
                function (array $item) {
                    return $item['age'] >= self::AGE_THRESHOLD;
                }
            );
        }
    }

    И для этого фильтра есть юнит тест:


    public function test_it_filters_adults()
    {
        $filter = new UserFilterAge();
        $users = [
            ['age' => 20],
            ['age' => 15],
        ];
    
        $this->assertCount(1, $filter($users));
    }

    Тест очень простой — добавляем двух пользователей и ожидаем, что фильтр вернет только одного из них, возраст которого 20 лет.


    Заметьте, при наличии только лишь этого теста, мы уже имеем 100% покрытие исходного кода класса UserFilterAge. Запустим мутационное тестирование и проанализируем результат:


    ./infection.phar --threads=4


    При стопроцентном покрытии кода мы имеем только 67% MSI — это уже подозрительно.


    Как считается MSI
    Metrics:
        Mutation Score Indicator (MSI): 47%
        Mutation Code Coverage: 67%
        Covered Code MSI: 70%

    Mutation Score Indicator (MSI)


    MSI равен 47%. Это ознaчает, что 47% всех сгенерированных мутаций не выжили (убиты, таймауты, ошибки). MSI является главной метрикой мутационного тестирования. Если Code Coverage равен 65%, то мы получаем разницу в 18% и это говорит о том, что процент покрытия строк кода в данном случае является плохим критерием оценки тестов.


    Формула подсчета:


    TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;
    
    MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100;

    Mutation Code Coverage


    Данный показатель равен 67%. В целом, он должен быть примерно равен показателю Code Coverage.


    Формула подсчета:


    TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;
    
    CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100;

    Covered Code Mutation Score Indicator


    MSI для кода, который покрыт тестами, равен 70%. Данный критерий показывает, насколько эффективны в реальности ваши тесты. То есть это процент всех убитых мутантов, сгенерированных для покрытого тестами кода.


    Формула подсчета:


    TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount;
    TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount;
    
    CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100;

    Если проанализировать метрики, получается, что MSI на 18 единиц меньше, чем показатель Code Coverage. Это говорит о том, что тесты гораздо менее эффективны согласно результатам мутационного тестирования, чем по результатам голого Code Coverage.


    Давайте посмотрим на сгенерированные мутации.


    Первая мутация:


    class UserFilterAge
    {
        const AGE_THRESHOLD = 18;
    
        public function __invoke(array $collection)
        {
            return array_filter(
                $collection,
                function (array $item) {
    -                return $item['age'] >= self::AGE_THRESHOLD;
    +                return $item['age'] > self::AGE_THRESHOLD;
                }
            );
        }
    }

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


    Мутационное тестирование сказало нам, что мы можем взять и заменить условие с ">=" на ">", и программа будет работать точно также. Помните, юнит тесты гарантируют нам, что программа работает так, как мы этого хотим? А раз тесты выполнились успешно с таким мутированным кодом, значит мы и ожидаем такое поведение.


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

    Давайте исправим ситуацию и убьём мутанта:


    /**
     * @dataProvider usersProvider
     */
    public function test_it_filters_adults(array $users, int $expectedCount)
    {
        $filter = new UserFilterAge();
    
        $this->assertCount($expectedCount, $filter($users));
    }
    
    public function usersProvider()
    {
        return [
            [
                [
                    ['age' => 15],
                    ['age' => 20],
                ],
                1
            ],
            [
                [
                    ['age' => 18],
                ],
                1
            ]
        ];
    }

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


    Вторая мутация:


    class UserFilterAge
    {
        const AGE_THRESHOLD = 18;
    
        public function __invoke(array $collection)
        {
    -       return array_filter(
    +       array_filter(
                $collection,
                function (array $item) {
                    return $item['age'] >= self::AGE_THRESHOLD;
                }
            );
    +      return null;   
        }
    }

    Тут не сразу очевидно, что произошло. Это довольно интересный мутационный оператор, заменяющий вызов функции в выражении "return functionCall();" на "functionCall(); return null;".


    Но почему вообще произошла такая мутация? Верно ли возвращать null, когда мы ожидаем отфильтрованный массив? Конечно не верно, и происходит это, потому что мы не указали тип возвращаемого значения в функции. МФ видит, что возвращаемое значение может быть null, и пытается подсунуть его. Infection довольно умный в этом плане, и, если функция содержит конкретный тип (не nullable, например int) возвращаемого значения, то мутировать код не будет. Анализируя этого мутанта, приходим к выводу, что надо добавить typehint:


    -    public function __invoke(array $collection)
    +    public function __invoke(array $collection): array

    Теперь сигнатура метода абсолютна понятна — передаем в фильтр массив, ожидаем массив.


    Запустим еще раз и проверим результат:



    Количество мутаций ожидаемо уменьшилось из-за добавления типа возвращаемого значения, и все мутанты убиты. Теперь мы имеем не только Code Coverage 100%, но и Mutation Code Coverage 100%, что является гораздо более показательным критерием качества ваших тестов.


    Этот простой пример показывает, что даже при наличии стопроцентного покрытия кода тестами, мутационное тестирование все еще может выявить проблемы и как бы покрывает ваш код "на более чем 100%".


    Если еще не прониклись, рассмотрим мутационные операторы помощнее — PublicVisibility и ProtectedVisibility. Смысл их в том, чтобы для каждого метода класса (кроме некоторых магических и абстрактных) менять модификатор доступа с public на protected, с protected на private.


    Это позволяет проверить необходимость открытости методов. Если такие мутанты оказываются выжившими, то можно сделать вывод, что публичный интерфейс вашего класса может быть уменьшен и, скорее всего, является избыточным. А в случае с ProtectedVisibility оператором — выживший мутант говорит о том, что метод должен быть изменен на private и нет ни одного наследника у класса, который бы использовал/переопределял родительский protected метод.


    Например, запустив Infection для немалоизвестного FosUserBundle, можно увидеть, что в нем имеется публичный метод isLegacy, открытость которого может быть уменьшена.


    ./infection.php --threads=4 --show-mutations --mutators=PublicVisibility,ProtectedVisibility


    Кроме этих двух случаев с выжившим и убитым мутантом, есть и другие. Например, изменение в цикле унарного оператора "++" у переменной счетчика на "--" может привести к тому, что цикл никогда не закончится, т.к. будет бесконечным. Задача фреймворка для мутационного тестирования — корректно обрабатывать такие ситуцаии и помечать мутанта особым статусом — Timeout. Такой исход является положительным и мутант не считается выжившим.


    В целом, с теорией разобрались, теперь посмотрим, что представляет собой Infection более детально, и какие есть еще альтернативы для PHP.


    Infection PHP


    Для работы, Infection требует установленное расширение xDebug для Code Coverage и PHP 7.0+.


    Рекомендуемым способом установки, с возможностью автоматического обновления (infection.phar self-update), является Phar архив.


    На данный момент поддерживаются из коробки два фреймворка для тестирования — PHPUnit (5, 6+) и PhpSpec.


    При первом запуске из корня вашего проекта будет создан конфиг infection.json.dist, который в последующем можно закоммитить в VCS. В нем указываются папки с исходным кодом для мутации, исключения, значение таймаута и т.д.


    Мутационное тестирование в целом требует человеческого анализа, поэтому все сгенерированные мутации после завершения работы МФ попадают в лог файл в той же папке — infection-log.txt.


    Опции


    Из самых интересных опций, с которыми запускается Infection, можно выделить следующие:


    --threads


    Это количество потоков, работающих параллельно для запуска всего набора сгенерированных мутантов. Значительно ускоряет время выполнения. Но тут есть оговорка: если ваши тесты каким-то образом зависят друг от друга или используют базу данных, использование этой опции может привести к многочисленным упавшим тестам, что крайне негативно скажется на результирующих метриках. Поэтому просматривать лог хотябы на начальных стадиях внедрения все-таки стоит.


    --show-mutations


    Сразу выводит diff с неубитыми мутантами на консоль, что позволяет моментально анализировать результат и исправлять тест при его написании.


    --mutators


    Перечисление мутационных операторов, мутирующих код. Удобно, если вы например хотите проверить только PublicVisibility и ProtectedVisibility операторы.


    ./infection.phar --mutators=PublicVisibility,ProtectedVisibility

    --min-msi и --min-covered-msi


    Эти две опции полезны, если вы запускаете Infection как один из шагов процесса сборки вашего проекта на Continious Integration сервере.


    --min-msi позволяет указать минимальное значение (в процентах) Mutation Score Indicator. Если указанное значение будет меньше фактического, то билд упадет. Данная опция заставляет при каждом билде покрывать большее количество строк кода.


    --min-covered-msi соответственно позволяет указать минимальное значение Covered Code MSI. Данная опция при каждом билде заставляет писать более эффективные и надежные тесты.


    Обе опции могут использоваться как по отдельности, так и вместе.


    ./infection.phar --min-msi=80 --min-covered-msi=95

    Использование с Travis CI


    before_script:
        - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar
        - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar.pubkey
        - chmod +x infection.phar
    script:
        - ./infection.phar --min-covered-msi=90 --threads=4

    Каждый релиз (Phar архив) подписывается приватным openssl ключом, поэтому кроме самого архива вам необходимо скачивать и публичный ключ.


    Как использовать мутационное тестирование?


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


    Ежедневное использование для разработчика


    Мутационное тестирование может быть полезно в ежедневной работе при написании новых тестов. Схема работы выглядит примерно так:


    • вы написали новый функционал, например тот же UserFilterAge из примера выше
    • этот код уже покрыт тестами
    • для проверки тестов, вы запускаете мутационное тестирование только для этого файла

    ./infection.phar --threads=4 --filter=UserFilterAge.php --show-mutations

    Анализируете выжившие мутанты и пытаетесь добиться хорошего показателя Covered Code MSI — т.е. чтобы процент убитых мутантов из всех сгенерированных для покрытого тестами кода стремился к 100. Это позволит максимально эффективно писать тесты.


    При использовании МТ вы заметите, что пишите более лаконичный код с большим количеством тестов. При этом будет использовано покрытие путей (branch coverage), когда все пути вашего кода протестированы, вместо обычного покрытия строк кода (line coverage).


    Ежедневное использование в проекте


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


    На мой взгляд, генерируя лишь только отчет, хороших показателей не добиться, поэтому лучше использовать опции --min-msi и/или --min-covered-msi.


    Например, мутационный фреймворк Infection мутационно тестирует сам себя при каждом билде. И если показатели падают, билд тоже падает.


    При постоянном использовании МТ, показатели MSI в проекте будут расти и вы сможете постепенно увеличивать значения опций --min-msi и --min-covered-msi.


    Почему иногда невозможно добиться 100% MSI?


    В мутационном тестировании есть понятие идентичных мутантов. То есть это мутации, которые приводят к идентичному коду с точки зрения логики. Примером такой мутации может служить следующий код:


    public function calculateExpectedValueAt(DateTimeInterface $date)
    {
        $diffInDays = (int) $this->startedAt->diff($date)->format('%a');
    
        $multiplier = $this->initialValue < $this->targetValue ? 1 : -1;
        $initialAveragePerDay = $this->calculateInitialAveragePerDay();
    
    -    return $this->initialValue + ($initialAveragePerDay * $diffInDays * $multiplier);
    +    return $this->initialValue + ($initialAveragePerDay * $diffInDays / $multiplier);
    }

    Смысл в том, что умножение числа и деление числа на ±1 приводит к идентичному результату, и такой мутант оказывается выжившим.


    В связи с этим, ожидать для всего кода стопроцентного MSI на практике не стоит. Для этого нужна мощная система регистрации идентичных мутантов и возможность исключения их из рузультирующих метрик.


    Альтернативы для PHP


    Единственной полноценной работающей альтернативой для Infection в PHP является Humbug — это вообще первый МФ в PHP. Из плюсов, в нем есть экспериментальная поддержка кеширования мутаций (incremental cache). То есть если какой-то файл не меняется и при этом никакие тесты, покрывающие его строки не удалялись при очередном запуске, то мутация не запускается и берется результат последнего запуска. Теоретически, это может значительно увеличить скорость работы, но может привести к ложным срабатываниям и погрешностям в метриках.


    С другой стороны, Humbug пока что не поддерживает PHPUnit 6+ и PhpSpec. Однако, главным отличием между Infection и Humbug на текущий момент является то, что Infection использует абстрактное синтаксическое дерево для мутирования кода (Abstract Syntax Tree (AST)). Построение AST возможно благодaря замечательному проекту Никиты Попова — PHP-Parser.


    Что же дает использование AST? Рассмотрим подробнее.


    Чтобы начать мутировать код, необходимо


    • разбить код файла на токены (функция token_get_all()), сложить их в массив
    • пробежаться по массиву и каждый токен, если это необходимо, заменить на другой, согласно мутационному оператору
    • из нового набора токенов собрать новый мутированный исходный код

    Пример токенов
    T_OPEN_TAG ('<?php ')
    T_BOOLEAN_AND ('&&')
    T_INC ('++')
    T_WHITESPACE (' ')
    ...

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


    • Находимся ли мы в теле функции? Заменять T_OPEN_TAG ('<?php ') нет никакого смысла
    • Будет ли валидным код после мутации? (например, сложение массивов ['a'] + ['b'] — это валидный код. А вот вычитание массивов ['a'] - ['b'] — это уже Fatal Error. Следовательно такую мутацию делать не нужно, и МФ должен проверять, не находится ли токен сложения между массивами.

    В результате, используя массив токенов, ответить на эти вопросы довольно затруднительно с точки зрения кода. Напротив, имея абстрактное синтаксическое дерево, это легко сделать, оперируя объектами, представляющими исходным код (Node\Expr\BinaryOp\Plus, Node\Expr\BinaryOp\Minus, Node\Expr\Array_).


    Вот как выглядят реализации мутационного оператора, меняющего "+" на "-" с проверкой массивов:


    Infection
    class Plus implements Mutator
    {
        public function mutate(Node $node)
        {
            return new BinaryOp\Minus($node->left, $node->right, $node->getAttributes());
        }
    
        public function shouldMutate(Node $node) : bool
        {
            if (!($node instanceof BinaryOp\Plus)) {
                return false;
            }
    
            if ($node->left instanceof Array_ && $node->right instanceof Array_) {
                return false;
            }
    
            return true;
        }
    }

    Humbug
    class Addition extends MutatorAbstract
    {
        public static function getMutation(array &$tokens, $index)
        {
            $tokens[$index] = '-';
        }
    
        public static function mutates(array &$tokens, $index)
        {
            $t = $tokens[$index];
            if (!is_array($t) && $t == '+') {
                $tokenCount = count($tokens);
                for ($i = $index + 1; $i < $tokenCount; $i++) {
                    // check for short array syntax
                    if (!is_array($tokens[$i]) && $tokens[$i][0] == '[') {
                        return false;
                    }
                    // check for long array syntax
                    if (is_array($tokens[$i]) && $tokens[$i][0] == T_ARRAY && $tokens[$i][1] == 'array') {
                        return false;
                    }
                    // if we're at the end of the array
                    // and we didn't see any array, we
                    // can probably mutate this addition
                    if (!is_array($tokens[$i]) && $tokens[$i] == ';') {
                        return true;
                    }
                }
                return true;
            }
            return false;
        }
    }

    Очевидно, что использование AST дает огромные преимущества. С ним легче работать, проще поддерживать и разбираться в коде, проще создавать новые мутационные операторы и проще анализировать код, ходя по веткам дерева.




    В общем, мутационное тестирование — это еще одно средство для повышения качества ваших тестов и кода в целом, стоящее обращения внимания.


    Если у вас есть опыт использования МТ на реальных проектах, или вы попробуете Infection и найдете интересные ошибки в коде — делитесь в комментариях о любых полезных кейсах.


    Используемая литература:


    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 41
    • 0
      имеется публичный метод isLegacy, открытость которого может быть уменьшена

      Как эта библиотека за меня определяет публичное апи моего класса? Вообще игры с видимостью внутри юнит тестов выглядят странно. Сделаю я метод protected вместо public и отвалится половина кода, использующего мой клас.
      • +3
        За вас она ничего не определяет, она пытается понизить модификатор доступа метода, и, если при этом тесты не падают, значит есть вероятность, что метод зря имеет такой уровень доступа. Такую мутацию надо проанализировать и либо дописать тест, либо сделать соответствующие изменения в коде.

        Как говорят умные люди, любой `public` или `protected`метод — это ваш ребенок. Как только вы его написали, вы обязаны следить за ним и за его Backward Compatibility. Особенно, это касается Open Source библиотек, классы которых вы можете наследовать.

        Пример из реального проекта: в базовом классе был `protected` метод, который переопределялся в дочерних классах. В результате рефакторинга иерархия классов изменилась, и из базового класса данный метод был удален, оставшись только в одном дочернем. Но модификатор доступа изменить забыли, т.е. он остался `protected`. Мутационное тестирование находит такие проблемы.
      • +2

        Кто-то использовал на реальных проектах? Есть реальные результаты по повышению качества кроме "и спи спокойно"?

        • +1
          Из того что я знаю: у нас на работе ребята на митапе рассказывали, что используют на многих новых проектах, причем выставляя на билдах уровень в 100%. Но там Ruby и МФ — Mutant, возможно, он позволяет регистрировать false-positives и упомянутые в статье идентичный мутации.

          Из опенсорса, @Ocramius использует в некоторых библиотеках МТ.
          • 0
            Тоже интересует вопрос с тем, насколько возрастает эффективность работы. То есть помимо времени на написание юнит-тестов, мне нужно будет уделить дополнительное время еще и на проверку тестов с помощью мутаций — хотелось бы, чтобы выигрыш в будущей поддержке был бы столь же ощутим.

            оффтоп
            И еще не дает покоя вопрос про то, кто будет контролировать контроллеров, в смысле, мутировать мутации :)
            • 0
              Билды либы проходят через мутационное тестирование
              • 0

                Сразу после прочтения статьи начал искать библиотеки для мутационного тестирования на джава.
                Нашёл мейвен плагин Pitest. Несколько тестов "запалились". В халатности, но, к сожалению, есть и ложны срабатывания на Apache ToStringBuilder что он
                мутацию в методе toString может заменить на Object.


                По поводу "тратить время" — юнит-тесты значительно ускоряют девелопмент, при изменениях дизайна "на лету". Мутационное тестирование просто позволяет удостоверится в том что CodeCoverage действительно такой какой он есть(или меньше)

        • 0
          А не подскажите как сгенерировать бадж с результатом?)
          • 0
            Хорошая идея, подумаю о добавлении этой фичи :)
            Пока это невозможно, т.к. надо хранить где-то значения.
          • 0
            Давно думал, что процент покрытия тестами не говорит о качестве этого покрытия.
            Спасибо за статью.
            Возник вопрос. Насколько я понял, чтобы использовать Infection, код должен быть написан в соответствии с типизацией php7? Или же налачия php-doc с секциями '@param' и '@return' будет достаточно (просто код писался ещё под php5.6, только недавно обновились до php7)?
            • 0
              Насколько я понял, чтобы использовать Infection, код должен быть написан в соответствии с типизацией php7?


              Нет, это не обязательно. Просто строгая типизация уменьшает количество сгенерированных мутантов (как в примере про return type).

              А так любой код может быть мутирован, с тайпхинтами или нет.
              • +1
                Ага. Ну то есть если у нас тест сломался при подмене на return null, то всё хорошо, а если не сломался, то с тестами что-то не то. Просто при наличии строгой типизации наличие такого мутанта (как в return type) бессмысленно. Теперь вроде ясно. Спасибо.
            • 0
              Забавно будет применить этот термин к ДНК-компьютерам
              • 0
                Думаю что и такие тесты и fuzzing применимы там, где пишется код который многократно переиспользуется. Для библиотек, ядерного функционала. Ну или просто сложного функционала с повышенными требованиями к качеству. Для обычного повседневного кода не факт что это нужно. Но техника интересная, стоит попробовать.
                • +1
                  Вы удивитесь, попробуя даже для вашего pet-project мутационное тестирование.
                  Тут дело не в крутости и сложности исходного кода, а в эффективности юнит тестов. А они то бывают плохими даже для простого кода.
                  • +1
                    Обычно в пет проджектах я тестами покрываю только то что неудобно тестировать иным способом (в т.ч. ручным) и од состояния мне хватило уверенности, т.е. там покрытие реально 5-10 % кода обычно. Хотя опять же зависит. Весь код который всякие ui делает и простую бизнес логику я обычно не тестирую вообще в пет проектах
                • +1
                  Выглядит крайне интересно, хотя у меня возникает вопрос о типе вносимых мутаций. Если мы мутируем только единичные операции, то область «проверки» очень ограничена (большинство других «невнимательных тестов» не будут пойманы). Если мы начинаем использовать широкие возможности мутаций (например, замена одного вызова метода класса на другой с похожей сигнатурой), то процесс генерации мутации становится уже трудноописуемым.

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

                  Вот, допустим, у нас была функция (простите, питон):
                  def foo(self):
                  self.do_job()
                  self.cleanup()

                  и приходит мутатор и дописывает в конец self.delete_all. А тесты этого не ловят. Плохие тесты? Или адекватные?
                  • +1
                    Не понял вопрос про единичные вхождения – поясните? Интересно.

                    Так как мутация изменяет код, который находится где-то внутри функции, то и unit-тесты (хотя бы один) должны упасть. Если не упали – значит, нет ни одного «внимательного» теста. В любом случае, решение «отрефакторить или нет» всегда точно определено в данном контексте. Или нет?

                    > замена одного вызова метода класса на другой с похожей сигнатурой
                    (o.0) Это… заменить `array_map` на `array_filter`? Зачем?

                    > приходит мутатор и дописывает в конец self.delete_all
                    А в `cleanup` уже есть `delete_all`? Это ведь уже имеет смысл только в контексте приложения (не языка), а, значит, подобные мутаторы нужно писать самому. Но есть ли в том смысл и резон?

                    ps: пользуюсь humbug в нескольких проектах, использовать сложные мутации типа «дописать метод» не вижу смысла – время и поддержка тестов не окупятся никогда (имхо).
                    • +1
                      Я посмотрел видео про cosmic rays (https://www.youtube.com/watch?v=jwB3Nn4hR1o), там перечислены типы мутаций. Да, оно более строго, чем я предполагал.

                      Про «единичные мутации» — это у меня в голове каша была, мой вопрос примерно был о том, что делается только одна мутация, а не набор мутаций. В принципе, «покрытие всеми возможными вариантами мутаций всех случаев» не требуется, так что вопрос не актуален. Или актуален? Если у нас есть набор точек для мутации и набор мутаций, то надо ли пробовать пермутацию мутаций (т.е. комбинации из двух разных мутаций, из трёх и т.д.)? Я вижу комбинаторный взрыв в этом.
                      • 0
                        Идея с комбинациями мутаций интересная, но это правда слишком сложно получается. Такое может окупиться только на каких-то очень ответственных участках кода, т. к. на возню с тестами и мутациями будет времени уходить в разы больше, чем на сам код.

                        P. S.: Для PHP сложно представить проекты, для которых такое окупится, мне кажется.
                        • +1
                          Спасибо за видео!

                          > надо ли пробовать пермутацию мутаций
                          Не нужно, т.к. не имеет смысла – комбинация мутаций провалит те же самые тесты, что и отдельные мутации сами по себе, – они уже помогают резко улучшить качество тестов и приносят профит за копейки (оставить проект проверяться на обед/ночь – не сложно).
                          Комбинация может найти баги только если если покрытие кода не плотное или невнимательное.
                          • +1
                            Любое покрытие тестами для практической программы несовершенно, потому что совершенное покрытие тестами имеет полный набор всех вариантов всех входных значений и сайд-эффектов.

                            Может быть так, что единичная мутация приводит к убитому мутанту, но вторая мутация помогает ему прорваться через тест.
                            • 0
                              Отличный пример багов, маскирующих друг друга! Пожалуй, можно записать в сторону идеи «не смешивать мутации».

                              Конечно, полное покрытие бессмысленно/невозможно/дорого. Есть подозрение, что десятилетия процессорного времени на «автоматически мутационный брутфорс» не окупятся при такой критичности багов – проще/быстрее/дешевле нанять специально обученных хороших тестеров, которые приложат свои биологические нейронные сети для поиска (-: Но нужно считать, конечно.

                              Вопрос в воздух: интересно, используют ли такое в авиационном или космическом софте?
                              • +3
                                Я немного о другом. Две мутации могут помогать мутанту прорваться через тривиальное, в область нетривиального.

                                Например, если у нас мутант заменяет "-1" на "+1", вот этот код свалится с ошибкой:

                                save_something[index + 1]
                                return get_something[index + 1]


                                Первый мутант свалится потому, что мы возвращем не то, что сохранили. А двойная мутация проверяет, что мы ловим ошибку «записали не туда». То есть наш код работает, а соседу данные попорчены.

                                Вопрос нагрузки на процессор я не знаю. У меня есть приложение с ~500 тестами (юнит и интеграционные с кассетами, плюс несколько реальных тестов со sleep'ами). Тесты отрабатывают за 25с. В приложении ~2700 строк. Часть из них — json schema, так что реальный размер — примерно 2500. Если предположить, что у нас в среднем одна мутация на строку, то полный прогон мутантов первого ранга (при условных 30 мутациях) — это 520 часов полного прогона.

                                Для мутантов второго ранга это уже 1627604 суток машинного времени. Даже в режиме «богатые дяди»ферма из сотни серверов с 32 ядрами каждый, уменьшит это до «всего лишь» 500 суток.

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

                                Мдя, не смешно. Явно надо учиться резать ветвления.
                                • +2
                                  Вы немного заблуждаетесь.
                                  Я не описал этот момент в статье, но у мутационного тестирования есть свои методики увеличения скорости работы.

                                  Самая основная, это запуск *только тех тестов, которые покрывают мутируемую строку*.

                                  Это означает, что для Мутации X вам потребуется запуск не 500 тестов, а только Y из них (1,2, 5 — сколько получится). Более того, следующий шаг, это запуск сначала самых быстрых тестов, из тех что покрывают строку.

                                  В целом, это кардинально снижает время, затраченное на мутационное тестирование.

                                  Не знаю, может стоит написать Часть 2 про разные методики улучшения производительности, и как МТ устроено внутри?
                                  • 0
                                    Когда вы тестируете только функции, которые мутировались, вы автоматически исключаете «ловлю» сайд-эффектов. С точки зрения «прочности тестов» юнит-тесты ловят только логику. А представьте себе, что у вас в результате мутации file(filename,O_READ) стало file(filename, O_WRITE). Сайд-эффекты от удалённого файла будут заметны только в другом коде.

                                    С мутационным тестированием на самом деле интересны две вещи: getting started для проекта с существующими тестами, и best practices.
                                    • 0
                                      Сайд эффекты тестятся не юнитами же.
                                      • 0
                                        Тыг мутации-то применимы не только к юнит-тестам, но и к любым другим тестам.

                                        Кстати, юнит-тесты без выхода на сайд-эффекты часто вообще смысла не имеют (а если имеют — то это уже не юнит-тесты). Я недавно про это писал (английский): medium.com/python-pandemonium/mock-or-not-to-mock-41965d33f175
                                        • 0
                                          Кстати, юнит-тесты без выхода на сайд-эффекты часто вообще смысла не имеют (а если имеют — то это уже не юнит-тесты).


                                          Если вы абстрагируете логику обработки данных от логики их хранения, то напишете юниты для обработки и интеграционные для хранение\чтения.
                                          При первом запуске либа спрашивает где ваши сорсы и где ваши тесты. Можно разложить юниты и интеграционные тесты по разным каталогам и указать для либы только каталог с юнитами. Все становится хорошо)

                                          Тыг мутации-то применимы не только к юнит-тестам, но и к любым другим тестам.

                                          Вот логика получения или сохранения данных довольно примитивна. Применять к ним мутации смысла особо нет.
                            • +1
                              оставить проект проверяться на обед/ночь – не сложно

                              У Java, Pitest умеет делать инкрементальное мутационное тестирование. Так что, это даже "Оставь на одну ночь, потом запускай хоть каждые 15 минут"

                          • +1
                            Скажите, пробовали ли вы Infection там, где пробовали Humbug? Интересна разница на «живых примерах».

                            Буду благодарен за любые полезные комментарии по этому поводу.
                            • +1
                              Про Infection я узнал только из этой статьи так что полезных данных нет пока что. Но выглядит он интересно, так что в скором времени опробую и сравню (не уверен что на этой неделе), напишу.
                              • 0
                                Опробовал, сравнил. Пробовал на трёх проектах, но результаты сравнимы поэтому в качестве примера приведу только один.

                                tl;dr: Infection генерирует больше мутантов, но «лишних» – количество выживших одинаковое. Кэширование в Humbug даёт лучшее ускорение, чем параллелизация в Infection.

                                В итоге, я не вижу для себя смысла переходить. Хотя, судя по статье, Infection понравился больше.




                                • Исходные данные: средний проект, покрываться тестами начал уже после релиза в прод, новый код пишется после тестов (в основном юниты, но есть и «юзкейс-тесты», которые дёргают базу). Всего тестов 460, 2344 assertions, проходят за полторы минуты полностью.
                                • Запуск. Infection сразу не завёлся. В Humbug я ставил таймаут 10 секунд. Infection начал работать только когда я выставил таймаут две минуты – время, большее чем время прохождение одного теста. Почему – не понял, но это не очевидно.
                                • Отчёты. Humbug сохраняет в лог ещё и остальные метрики вроде MSI/MCS/покрытия, генерации и ресурсов. Infection – только диффы. Так что перед написанием коммента пришлось ещё раз полностью запустить Infection.
                                • Скорость. Без ускорений Humbug быстрее: 1,15 часа против почти двух (из-за меньшего количества мутантов?). Без тестов трогающих базу, с кэшированием и параллелизацией Humbug опять же быстрее: 3-5 минут в среднем против 20 минут.
                                • Мутанты. Infection сгенерировал на 30% больше мутантов (2360 против 1682), но количество выживших осталось ровно таким же (156). Эти «лишние» мутанты размазались между «убитыми» и «не покрыты тестами». MSI/MCS/Covered различаются на 1-2%.
                          • +1
                            Полез поискать в питоне, вижу:
                            mutpy, mutmut и cosmic-ray. Ни один из них не пакетирован в дебиане, к сожалению.
                            • +1
                              Мутационное тестирование — это интересно и сама утилита отличная, спасибо вам за нее. Запустил на проекте и нашел несколько мест для улучшения набора данных в тестах.

                              Постоянно, вряд ли, буду прогонять с тестами, а вот в момент написание основных тестов пригодится, чтобы улучшить их покрытие и качество.
                              • +1
                                А сколько у вас занимает МТ для вашего проекта?
                                Могли бы добавить в билд с опцией `--min-covered-msi`.

                                Данный показатель позволит не снижать качество существующих и новых тестов. Но если писать новые не будете, билд падать не будет.
                                • 0
                                  Проект маленький, несколько секунд. Подумаю над этим, спасибо)
                              • 0
                                Автор скромно не упоминает, что сам является создателем столь рекламируемого им фреймворка пруф
                                • +2
                                  Из вашего комментария не совсем понял, хорошо это или плохо?

                                  Но а целом да, не упоминал специально.

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