Когда-то давно, в те благословенные времена, когда программисты еще наивно полагали, что покрытие кода тестами — это показатель качества, я тоже разделял эту иллюзию. Восемьдесят процентов покрытия? Отлично! Девяносто? Великолепно! Сто? Да вы просто параноик, милейший, возвращайтесь в Скворечник, а то на ужин опоздаете.
А потом я написал библиотеку для мутационного тестирования. И понял, что все эти годы мы просто считали, сколько строк кода посещает тестовый раннер, гордясь собой, как малолетние дети, научившиеся считать до десяти.
Покрытие кода — индульгенция для тупых и ленивых
Представьте, что вы написали функцию:
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 делает следующее:
Парсит исходный код в AST (абстрактное синтаксическое дерево)
Применяет мутации непосредственно к узлам AST
Конвертирует измененное AST обратно в код
Компилирует его в памяти
Загружает в 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.
Теперь можете попробовать проверить, насколько хороши ваши тесты. Я почти уверен, что результат вас удивит. И не в лучшую сторону.
