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

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

Покрытие кода — индульгенция для тупых и ленивых

Представьте, что вы написали функцию:

def calculate_discount(price, percentage)
    when percentage > 0 and percentage <= 100,
  do: price * (100 - percentage) / 100

Теперь вы пишете тест:

test "calculate discount" do
  result = calculate_discount(100, 10)
  assert is_number(result)
end

Покрытие? Сто процентов. Тест проходит? Да. Функция работает правильно? Вот это уже вопрос интересный. Тест проверяет лишь то, что функция возвращает число. Любое число. Даже -42, если вы случайно перепутаете знак в арифметической операции.

Мутационное тестирование решает эту проблему радикально и с наслаждением садиста, забывшего выпить таблетки. Оно берет ваш код, вносит в него намеренные ошибки (мутации) и запускает тесты. Если тест все еще проходит — значит, ваш тест бесполезен, как зонтик в космосе.

Как это работает: тонкая хирургия на открытом AST

muex — это библиотека для мутационного тестирования, работающая с Elixir, Erlang и, теоретически, любым другим языком, который вам взбредет в голову поддержать. Архитектура не зависит от языка (language-agnostic), инъекция зависимостей, шесть стратегий мутаций, параллельное выполнение — все атрибуты недорогого швейцарского ножа.

Но главное — она работает с компилируемым языком. А это не так тривиально, как кажется.

Проблема компилируемых языков

В интерпретируемых языках вроде Python мутационное тестирование — это почти развл��чение. Берете исходник, вносите изменение, запускаете. Красота. В компилируемых языках все сложнее. У вас нет прямого доступа к исполняемому коду — между вами и BEAM стоит компилятор, который превращает ваш изящный код в байт-код с предсказуемостью алхимика средневековья.

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

Решение: hot module swapping и хирургия AST

Вместо этого muex делает следующее:

  1. Парсит исходный код в AST (абстрактное синтаксическое дерево)

  2. Применяет мутации непосредственно к узлам AST

  3. Конвертирует измененное AST обратно в код

  4. Компилирует его в памяти

  5. Загружает в BEAM через hot module swapping

Вот как выглядит ключевой фрагмент:

defp compile_and_load(source, module_name) do
  :code.purge(module_name)
  :code.delete(module_name)
  [{^module_name, binary}] = Code.compile_string(source)

  case :code.load_binary(module_name, ~c"nofile", binary) do
    {:module, ^module_name} -> {:ok, module_name}
    {:error, reason} -> {:error, reason}
  end
rescue
  e -> {:error, e}
catch
  kind, reason -> {:error, {kind, reason}
end

Красиво? — Не особенно. Работает? — Как метроном.

Стратегии мутаций: шесть способов сломать ваш код

muex предлагает шесть типов мутаций. Каждая — это маленькая диверсия в вашем коде:

1. Арифметические операторы

Самое очевидное. Меняем + на -, * на /, а иногда просто заменяем всю операцию на ноль или единицу.

case ast do
  {:+, meta, [left, right]} = original_ast ->
    line = Keyword.get(meta, :line, 0)

    [
      build_mutation(original_ast, {:-, meta, [left, right]}, "+ to -", context, line),
      build_mutation(original_ast, 0, "+ to 0 (remove)", context, line)
    ]

Если ваш тест не заметит, что 100 + 10 вдруг превратилось в 100 - 10 или просто в 0, то вам стоит переосмыслить свой подход к написанию тестов.

2. Логические операторы

and становится or, true превращается в false, а not x просто исчезает, оставляя x.

case ast do
  {:and, meta, args} ->
    [build_mutation({:or, meta, args}, "and to or", context, Keyword.get(meta, :line, 0))]

  {:or, meta, args} ->
    [build_mutation({:and, meta, args}, "or to and", context, Keyword.get(meta, :line, 0))]

  true ->
    [build_mutation(false, "true to false", context, 0)]

3. Операторы сравнения

== на !=, > на <, и так далее. Если у вас в коде есть непроверенные граничные условия, мутационное тестирование найдет их, как спаниель — утку на охоте.

4. Литералы

Числа увеличиваются или уменьшаются на единицу, строки становятся пустыми, списки тоже. Атомы меняются на другие атомы.

5. Вызовы функций

Самое жестокое — просто удаляем вызов функции. Или меняем местами первые два аргумента. Если ваш код все еще работает после этого, значит, вызов функции был чисто декоративным.

6. Условные выражения

if превращается в unless, ветки меняются местами, условия инвертируются.

Архитектура: как не застрелиться в процессе

Самое интересное в muex — это не сами мутации, а то, как библиотека их применяет.

Language Adapters

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

@impl true
def parse(source) do
  case Code.string_to_quoted(source) do
    {:ok, ast} -> {:ok, ast}
    {:error, reason} -> {:error, reason}
  end
rescue
  e -> {:error, e}
end

@impl true
def unparse(ast) do
  {:ok, Macro.to_string(ast)}
rescue
  e -> {:error, e}
end

Для Elixir это тривиально — используем встроенные Code и Macro. Для Erlang пришлось нырять в :erl_parse и :epp_dodger. Но суть в том, что остальная система об этом ничего не знает. Захотите поддержать LFE или Gleam — пишите адаптер, остальное работает из коробки.

Параллельное выполнение

Тестирование сотен мутаций последовательно — подобно чтению «Войны и мира» с крейсерской скоростью одно слово в минуту. Технически возможно, да и мазохистам должно понравиться. muex запускает мутации параллельно, используя worker pool:

def run_all(
      mutations,
      file_entry,
      language_adapter,
      dependency_map,
      file_to_module,
      opts \\ []
    ) do
  max_workers = Keyword.get(opts, :max_workers, 4)
  {:ok, pool} = Muex.WorkerPool.start_link(max_workers: max_workers)

  results =
    Muex.WorkerPool.run_mutations(
      pool,
      mutations,
      file_entry,
      language_adapter,
      dependency_map,
      file_to_module,
      opts
    )

  GenServer.stop(pool)
  results
end

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

Оптимизация: когда мутаций слишком много

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

muex предлагает три уровня оптимизации:

  • Conservative: сокращает количество мутаций на 50-65%, влияние на точность меньше 1%

  • Balanced: сокращает на 70-85%, влияние 5-10%

  • Aggressive: сокращает на 85-95%, влияние 10-15%

Оптимизатор использует семь эвристик: от обнаружения эквивалентных мутаций до приоритизации граничных условий. В реальном проекте (корзина покупок, 440 строк, 84 теста) консервативный режим сократил время выполнения с трех минут до одной, практически не изменив mutation score.

Результаты: момент истины

После того как все мутации протестированы, вы получаете отчет:

Mutation Testing Results
==================================================
Total mutants: 342
Killed: 287 (caught by tests)
Survived: 55 (not caught by tests)
Invalid: 0 (compilation errors)
Timeout: 0
==================================================
Mutation Score: 83.9%

Killed — это хорошо. Это значит, что тест упал, когда вы сломали код. Тест делает то, что должен.

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

Invalid — мутация привела к ошибке компиляции. Обычно это нормально — не все мутации семантически корректны.

Mutation score 83.9% означает, что 83.9% мутаций были пойманы тестами. Это намного честнее, чем code coverage 95%, который может означать, что вы просто вызвали каждую функцию хоть раз, не проверяя результат.

Подводные камни разработки

Проблема 1: Сопоставление мутаций с оригинальным AST

Когда вы мутируете узел AST, а потом пытаетесь найти его в полном дереве модуля, возникает проблема: как понять, что вы нашли именно тот узел? У Elixir AST нет уникальных идентификаторов узлов.

Решение: структурное равенство плюс номер строки.

defp matches_mutation?(node, original_ast, mutation_line) do
  node_line = get_node_line(node)
  node_line == mutation_line && structurally_equal?(node, original_ast)
end

defp structurally_equal?({form1, _meta1, args1}, {form2, _meta2, args2}) do
  form1 == form2 && args_equal?(args1, args2)
end

Это работает в 99% случаев. В оставшемся одном проценте вы получаете invalid mutation и идете дальше.

Проблема 2: Восстановление оригинального модуля

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

def restore(module_name, original_binary) do
  :code.purge(module_name)
  :code.delete(module_name)

  case :code.load_binary(module_name, ~c"nofile", original_binary) do
    {:module, ^module_name} -> :ok
    {:error, reason} -> {:error, reason}
  end
rescue
  e -> {:error, e}
end

Сохраняем оригинальный байт-код перед применением мутации, потом восстанавливаем его через :code.load_binary. Элегантно и работает.

Проблема 3: Фильтрация файлов

Если мутировать все файлы в проекте, включая Mix tasks, reporters, supervisors и прочую инфраструктуру, вы получите тысячи мутаций, 90% из которых бессмысленны.

muex анализирует файлы и фильтрует их по сложности кода:

  • Пропускает behaviours, protocols, supervisors

  • Игнорирует простые файлы (конфиги, репортеры)

  • Приоритизирует файлы с бизнес-логикой (условия, арифметика, pattern matching)

Это сокращает количество тестируемых файлов на порядок, сосредотачиваясь на том, что действительно важно.

Заключение: зачем вам это нужно

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

Запускать muex на каждый коммит — избыточно. Запускать раз в неделю или перед релизом — разумно. Запускать никогда — значит жить с иллюзией, что 95% code coverage что-то значит.

Код доступен на GitHub под лицензией GPL-3.0. Можете использовать как mix dependency, hex archive или standalone escript. Документация на https://hexdocs.pm/muex.

Теперь можете попробовать проверить, насколько хороши ваши тесты. Я почти уверен, что результат вас удивит. И не в лучшую сторону.