Последние 10 недель я и еще несколько человек работали над инструментом проверки мутаций под названием Faultify. Этот инструмент намеренно вводит логические ошибки в кодовую базу для проверки качества тестирования.

Введение

Что касается Stryker, пока что это единственный лучший вариант для выполнения мутаций исходного кода .net, Faultify предлагает тестирование мутаций байтового кода. Этот метод выбран по двум причинам: 1) Чтобы выяснить, являются ли мутации байт-кода лучшей альтернативой мутациям исходного кода 2) Обеспечить альтернативную утилиту тестирования мутаций для экосистемы .NET.

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

Содержание

  • Что такое мутационное тестирование
  • Что такое мутация (псевдо бесконечные циклы)
  • Техника мутации (исходный код и переключение мутаций / байтовый код)
  • Оптимизация (файлы с отображением в память, RAM-диск, параллельное выполнение, упаковка бункеров, покрытие кода).
  • Покрытие мутаций
  • Байт-код против исходного кода

Что такое мутационное тестирование

Вставка ошибок, внедрение ошибок, тестирование мутаций, внедрение мутаций - термины, относящиеся к одному и тому же предмету. С помощью внедрения мутации вносятся изменения в логику кодовой базы. Если вводится логическая мутация (изменение) и тест все равно проходит успешно, то тест может быть не полностью надежным. Этот факт можно использовать для расчета качества модульного теста.

Что такое мутация

Мутация - это изменение операторов, постоянных значений или переменных склонений. Примеры возможных мутаций:

  • Арифметические (+, -, /, *,%) операторы.
  • Выражения присвоения (+ =, - =, / =, * =,% =, -, ++).
  • Операторы эквивалентности (==,! =).
  • Логические операторы (&&, ||).
  • Побитовые операторы (^, |, &).
  • Операторы ветвления (if (условие), if (! Условие)).
  • Переменные литералы (истина, ложь).
  • Изменять постоянные поля (строка, число, логическое значение).

Операторы, указанные выше, следует преобразовать в вариант, который нарушает логику. Например, знак «+» можно заменить на «-», «‹ »на« ›», «истина» на «ложь» и т.д. то же значение, например от «1 + 1» до «1 * 1», поэтому для этих мутаций должны выполняться все варианты «+, -, *, /» для достижения наилучшего результата.

// Original
public int Add(a, b) {
   return a + b;
}
// Mutation 1
public int Add(a, b) {
   return a - b;
}
// Mutation 2
public int Add(a, b) {
   return a * b;
}

Мутация бесконечного цикла

Мутация, например, while (true), может вызвать бесконечный цикл. Это проблема, поскольку из-за этого тестовый сеанс будет работать бесконечно. С этим можно справиться несколькими способами:

  1. Первоначальный тестовый прогон выполняется для расчета времени выполнения всех модульных тестов без мутаций, затем этот показатель используется в качестве значения тайм-аута процесса.
  2. Перед выполнением тестового прогона проверяется, может ли мутация вызвать бесконечный цикл. Это более быстрое решение, потому что вам не нужно прерывать тестовый процесс и запускать его снова, если происходит мутация бесконечного цикла. Однако это может быть очень трудно обнаружить заранее по следующим причинам:
    1) Цикл может содержать множество условий, которые потенциально могут прервать цикл с помощью возврата или прерывания. Все условия должны быть проверены, чтобы узнать, вызывает ли мутация бесконечный цикл.
    2) Могут быть разные циклы с разными мутациями. Относительно легко обнаружить, что мутация `while (false)` в `while (true)` приводит к бесконечному циклу. Однако это намного сложнее для мутации `while (a‹ b) `или` while (a ›b)`.

Помимо бесконечных циклов, псевдобесконечность также является краевым случаем, который следует учитывать. Псевдобесконечный цикл означает, что цикл требует некоторого времени для выполнения, однако он конечен. Мутация цикла for «++» на «- -» изменит направление итерации, что может занять много времени (обычно int.Max раз), прежде чем она завершится.

Техника Мутации

Есть два основных способа изменить логику кода:

  1. На уровне исходного кода (изменение синтаксических деревьев и компиляция мутаций)
  2. На уровне байтового кода (изменение байтового кода / CIL в скомпилированной сборке).

Переключение мутации

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

Поэтому методика исходного кода часто используется со схемами «Переключение мутации» / «Мутант». Короче говоря, это означает, что все мутации компилируются в двоичный файл. Затем процесс тестирования будет включать или отключать мутации, например, с помощью переменной среды. Это пример того, как знак «+» заменяется на «-» и как выглядит «*»:

public int Add(int a, int b) {
   if (Environment.GetEnvironmentVariable("ActiveMutant") == 0) { 
     return a - b;
   } else { 
     return a * b;        
   } 
}

Таким образом, в процессе тестирования можно установить для переменной среды ActiveMutant значение «0» для выполнения «-» и 1 для «*».

Мутация байтового кода

Преимущество манипуляции с байтовым кодом состоит в том, что измененный исходный код не нужно перекомпилировать, и не все мутации нужно вводить заранее. Для Faultify мы используем «Mono.Cecil». Это отличная библиотека для управления «IL-CODE (CIL)». Главный недостаток, с которым я столкнулся, заключается в том, что библиотека очень плохо документирована, что затрудняет начало работы.

«Mono.Cecil» состоит из следующей структуры:

Модуль - это скомпилированная сборка, типы - это, например, классы, и эти классы имеют методы, свойства и поля. На этих уровнях, как описано ранее, могут выполняться мутации.

Изменим следующий метод:

public int Addition(int lhs, int rhs)
{
    return lhs + rhs;
}

Пример кода

На левом изображении вы можете увидеть IL-код метода «Добавление», проверенного с помощью «Il-Spy».

Инструкция IL_0003 имеет код операции «добавить». Если мы изменим это значение на «sub», то здесь операция станет вычитанием, а не сложением (dn-spy можно использовать для редактирования IL-кода вручную). На следующем изображении показано движение «добавить» к «подпрограмме» с помощью «Mono.Cecil»:

Здесь вы можете видеть, что aModuleDefinition имеет много TypeDefinitions и что тип имеет много MethodDefinitions, которые, в свою очередь, имеют Instructions.. Мы хотим изменить код операции «добавить» на «дополнительный» код операции.

Подробное представление о значении и крайних случаях кодов операций можно найти на страницах Википедия и Microsoft.

Big Gotcha

В Рим ведет множество троп. То же самое и для сравнения значений в IL-Code. Это показано в следующем списке с вариантом сравнения ветвления и вариантом только для сравнения (их значение см. В Википедии).

  1. blt: эффект идентичен выполнению инструкции clt, за которой следует переход brtrue к конкретной целевой инструкции.
  2. bgt: эффект идентичен выполнению инструкции cgt, за которой следует переход brtrue к конкретной целевой инструкции.
  3. bge: эффект идентичен выполнению инструкции clt (clt.un для чисел с плавающей запятой) с последующим переходом brfalse к конкретной целевой инструкции.
  4. beq: эффект аналогичен выполнению инструкции ceq с последующим переходом brtrue к конкретной целевой инструкции.

Оказывается, компилятор обычно оптимизирует поток управления, переводя логический оператор, такой как , в свою инструкцию ветвления дополнения IL (clt). Следовательно, может случиться так, что разные компиляторы генерируют разный IL-код. Мой компилятор всегда будет генерировать оператор сравнения (clt), однако на другом ПК он также может использовать вариант ветвления (blt). Этот сценарий может сбить с толку, если определенные мутации не работают. В этой статье Microsoft этот вопрос рассматривается глубже.

Оптимизация

«Dotnet test» имеет время загрузки / завершения процесса около 1 секунды. Например, возьмем 2000 мутаций, это займет 2000 секунд (33 минуты) только для управления процессом. Поэтому оптимизация важна для хорошо работающего инструмента мутации. Для этого есть несколько возможностей:

Запуск тестов из памяти

Dotnet test - это оболочка над vs-test-console, которые являются внешними процессами. В идеальном сценарии вы хотели бы иметь возможность загружать модульные тесты в память, а затем запускать их из кода. Это может сэкономить около 1 секунды на тестовый запуск. Насколько мне удалось выяснить, vsconsole можно использовать только как внешний процесс (`VsConsoleWrapper`). Однако Nunit может запускаться непосредственно из кода. Хотя это ограничит поддержку других тестовых фреймворков.

Файлы с отображением памяти и / или Ramdisk

Отображенные в память файлы (действительно удивительная недооцененная техника) и / или RAM-диск используют оперативную память, что позволяет очень быстро читать / писать файлы. RAM-диск можно использовать для запуска всего процесса тестирования, а файлы с отображением памяти можно использовать для мутируемых сборок.

Упаковка корзины мутации

Мутация может привести к сбою или успеху теста, поэтому невозможно одновременно изменить две мутации, охватываемые тестом. Если да, то невозможно определить, какая мутация привела к сбою теста. Этот сценарий представляет собой идеальную проблему с упаковкой в ​​контейнеры. Тест - это корзина, а мутация - это пакет. Каждый тест может иметь только одну мутацию за раз, и вы можете запустить один тест в рамках сеанса тестирования. Следуя этому алгоритму, можно одновременно выполнять несколько мутаций. Чтобы реализовать этот алгоритм, необходимо измерить покрытие кода.

Параллельное выполнение мутаций

Сборка изменена, и тесты содержат ссылку на эту сборку. Невозможно запустить несколько сеансов тестирования, потому что они изменят одну и ту же сборку. Вдобавок CLR блокирует сборки тестового процесса. Чтобы использовать несколько процессов, тестируемые тесты и сборки должны быть продублированы, чтобы каждый тестовый процесс имел свои собственные файлы сборки. Faultify дублирует весь тестовый проект N раз. Затем в ходе тестовых прогонов можно получить тестовый проект, а по завершении также освободить его, чтобы другие могли его использовать.

Покрытие мутаций

Faultify измеряет покрытие кода, внедряя функцию статического регистра как в модульный тест, так и во все методы тестируемой сборки. Когда выполняется запуск теста покрытия кода, вызываются эти статические функции. Во-первых, запускаемый модульный тест регистрирует свое имя, после чего все методы, вызываемые этим модульным тестом, зарегистрируют свой «Entity Handle». После этого запуска мы можем точно увидеть, какие модульные тесты охватывали какие методы. Сложности бывают:

  1. Поскольку модульные тесты могут покрывать код за интерфейсами, невозможно использовать отражение для проверки покрытия кода.
  2. Чтобы узнать, покрывается ли мутация, нам нужно как-то идентифицировать эту мутацию. При этом у нас возникли сложности, вместо этого мы регистрируем только дескриптор объекта метода. Это означает, что тестовое покрытие основано на методе, а не на мутациях, что означает, что будет выполнено больше мутаций.

Байт-код и исходный код

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

  • И stryker, и faultify имеют примерно одинаковое количество мутаций и одинаковое количество очков.
  • В этом конкретном проекте stryker работает быстрее, если настроены 1-2 тестовых прогона.
  • Faultify становится значительно быстрее, если настроено более 2 участников тестирования. В более крупном проекте с «259 мутациями» Faultify потребовалось 55 секунд, что составляет «0,21» секунды для мутации. В то время как для страйкера это было около «150» секунд, что соответствует перестановке «0,58» секунды. Это увеличение скорости примерно на «58%».

Мутации исходного кода с переключением мутаций:

Плюсы:

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

Минусы

  • Контроль над отдельными мутациями ограничен, поскольку мутации нельзя внедрить без перекомпиляции во время выполнения.
  • Изменения констант, имен методов, модификаторов доступа невозможны.
  • При некоторых мутациях могут возникать ошибки компиляции.

Байт-код

Плюсы:

  • Перекомпиляция не требуется.
  • Интегрируется со всеми языками .NET, работающими на CIL.
  • Больше гибкости и контроля над мутациями, поскольку мутации можно внедрить без перекомпиляции во время выполнения.
  • Возможны мутации констант, имен методов, модификаторов доступа.
  • Детальный контроль, поскольку можно вводить только необходимые мутации; Это полезно при проверке с помощью «ILSPY» или «DNSPY».
  • Доступ к IL-коду дает большую гибкость.

Минусы

  • Сложнее (не невозможно) показать точное местоположение / строку мутации, поскольку в IL-коде нет строк кода.
  • Некоторые мутации, такие как мутации массива, требуют сложных IL-структур.
  • Параллельно запускать тесты на мутации труднее (а возможно, и невозможно) по сравнению с исходным кодом.
  • Расчет покрытия кода для отдельных мутаций практически невозможно, поэтому в Faultify используется что-то вроде покрытия на основе методов.

Подведение итогов

Этот проект можно найти на странице Faultify GitHub. Это все еще ранняя фаза, и впереди есть большие планы по ускорению Faultify и поддержке более сложных мутаций. Тем самым мы надеемся предоставить хороший альтернативный способ проведения мутационного тестирования в экосистеме Dotnet.