Packages

Language-agnostic mutation testing library for Elixir, Erlang, and other BEAM languages. Evaluates test suite quality by introducing deliberate bugs into code and verifying that tests catch them. Intelligent file filtering, 18 mutation strategies, sound equivalent-mutant handling, and more.

Current section

Files

Jump to
muex lib muex.ex
Raw

lib/muex.ex

defmodule Muex do
@moduledoc """
Muex - Mutation testing library for Elixir, Erlang, and other languages.
Muex provides a language-agnostic mutation testing framework with dependency
injection for language adapters, making it easy to extend support to new languages.
## Architecture
- `Muex.Language` - Behaviour for language adapters (parse, unparse, compile)
- `Muex.Mutator` - Behaviour for mutation strategies
- `Muex.Loader` - Discovers and loads source files
- `Muex.Compiler` - Compiles mutated code and manages hot-swapping
- `Muex.Runner` - Executes tests against mutants
- `Muex.Reporter` - Reports mutation testing results
## Usage
Run mutation testing via Mix task:
mix muex
With options:
mix muex --files "lib/**/*.ex" --mutators arithmetic,comparison --fail-at 80
## Creating a Language Adapter
To add support for a new language, implement the `Muex.Language` behaviour:
defmodule Muex.Language.MyLanguage do
@behaviour Muex.Language
@impl true
def parse(source), do: {:ok, parse_to_ast(source)}
@impl true
def unparse(ast), do: {:ok, ast_to_string(ast)}
@impl true
def compile(source, module_name), do: {:ok, compiled_module}
@impl true
def file_extensions, do: [".mylang"]
@impl true
def test_file_pattern, do: ~r/_test\.mylang$/
end
## Creating a Mutator
To add a new mutation strategy, implement the `Muex.Mutator` behaviour:
defmodule Muex.Mutator.MyMutator do
@behaviour Muex.Mutator
@impl true
def mutate(ast, context) do
# Return list of mutations
[]
end
@impl true
def name, do: "MyMutator"
@impl true
def description, do: "Custom mutation strategy"
end
"""
alias Muex.Reporter.Html, as: HtmlReporter
alias Muex.Reporter.Json, as: JsonReporter
@doc """
Executes the full mutation testing pipeline from a `%Muex.Config{}`.
Returns `{:ok, %{results: results, score: mutation_score}}` on success
or `{:error, reason}` on failure. Never calls `Mix.raise` or `System.halt`;
the caller decides how to handle the outcome.
"""
@spec run(Muex.Config.t()) :: {:ok, map()} | {:error, String.t()}
def run(%Muex.Config{} = config) do
log("Loading files from #{config.files}...", config.verbose)
case Muex.Loader.load(config.files, config.language) do
{:ok, []} ->
{:ok, %{results: [], score: 0.0}}
{:ok, all_files} ->
log("Found #{length(all_files)} file(s)", config.verbose)
do_run(config, all_files)
end
end
defp do_run(config, all_files) do
files = maybe_filter(all_files, config)
log("Generating mutations...", config.verbose)
all_mutations =
files
|> Enum.flat_map(fn file ->
context = %{file: file.path}
Muex.Mutator.walk(file.ast, config.mutators, context)
end)
|> maybe_optimize(config)
|> maybe_cap(config)
if all_mutations == [] do
{:ok, %{results: [], score: 0.0}}
else
run_mutations(config, files, all_mutations)
end
end
defp maybe_filter(files, %Muex.Config{filter: false} = config) do
log("Skipping file filtering", config.verbose)
files
end
defp maybe_filter(files, %Muex.Config{filter: true} = config) do
log("Analyzing files for mutation testing suitability...", config.verbose)
{included, excluded} =
Muex.FileAnalyzer.filter_files(files, min_score: config.min_score, verbose: config.verbose)
log(
"Selected #{length(included)} file(s), skipped #{length(excluded)} file(s)",
config.verbose
)
included
end
defp maybe_optimize(mutations, %Muex.Config{optimize: false}), do: mutations
defp maybe_optimize(mutations, %Muex.Config{optimize: true, verbose: verbose} = config) do
log("Applying mutation optimization...", verbose)
opts = Muex.Config.optimizer_opts(config)
optimized = Muex.MutantOptimizer.optimize(mutations, opts)
if verbose do
report = Muex.MutantOptimizer.optimization_report(mutations, optimized)
log("Original mutations: #{report.original_count}", true)
log("Optimized mutations: #{report.optimized_count}", true)
log("Reduction: #{report.reduction} (-#{report.reduction_percentage}%)", true)
log("Average impact score: #{report.average_impact_score}", true)
end
optimized
end
defp maybe_cap(mutations, %Muex.Config{max_mutations: max})
when max > 0 and length(mutations) > max do
Enum.take(mutations, max)
end
defp maybe_cap(mutations, _config), do: mutations
defp run_mutations(config, files, all_mutations) do
log("Testing #{length(all_mutations)} mutation(s)", config.verbose)
log("Analyzing test dependencies...", config.verbose)
dependency_map = Muex.DependencyAnalyzer.analyze(config.test_paths)
file_to_module = Map.new(files, fn file -> {file.path, file.module_name} end)
log("Running tests...\n", config.verbose)
results =
Enum.flat_map(files, fn file ->
file_mutations = Enum.filter(all_mutations, fn m -> m.location.file == file.path end)
if match?([_ | _], file_mutations) do
Muex.Runner.run_all(
file_mutations,
file,
config.language,
dependency_map,
file_to_module,
max_workers: config.concurrency,
timeout_ms: config.timeout_ms,
verbose: config.verbose,
test_paths: config.test_paths
)
else
[]
end
end)
case output_report(results, config.format, config.verbose) do
{:error, _} = err -> err
_ -> build_result(results)
end
end
defp build_result(results) do
total = length(results)
killed = Enum.count(results, &(&1.result == :killed))
score =
if total > 0 do
Float.round(killed / total * 100, 2)
else
0.0
end
{:ok, %{results: results, score: score}}
end
defp output_report(results, "json", _verbose) do
log(JsonReporter.to_json(results))
end
defp output_report(results, "html", verbose) do
HtmlReporter.generate(results)
log("HTML report generated: muex-report.html", verbose)
end
defp output_report(results, "terminal", _verbose) do
Muex.Reporter.print_summary(results)
end
defp output_report(_results, other, _verbose) do
{:error, "Unknown format: #{other}. Use terminal, json, or html"}
end
defp log(msg, verbose \\ true) do
if verbose do
if Code.ensure_loaded?(Mix) and function_exported?(Mix, :shell, 0) do
Mix.shell().info(msg)
else
IO.puts(msg)
end
end
end
end