mutare_decimal

Hex.pm Hexdocs CI License

Mutation-testing mutators for Decimal, built as a plugin for Mutare.

In money and precision code, tests may miss a sub where an add belongs, a lt? that should be lte?, or an incorrect rounding mode. Mutare's built-in mutators treat Decimal.add(a, b) as an ordinary remote call. This plugin adds Decimal-specific mutations — a swapped operation, a reversed comparison, a dropped rounding refinement — to test whether those changes cause test failures. A surviving mutant can indicate a missing assertion.

Install

Add it (and Mutare) as dev/test dependencies:

def deps do
[
{:mutare, "~> 0.4.1", only: [:dev, :test], runtime: false},
{:mutare_decimal, "~> 0.2", only: [:dev, :test], runtime: false}
]
end

(Decimal itself is not a dependency of this plugin — the mutators match calls purely syntactically. Your own project already supplies Decimal.)

Enable

List the families in .mutare.exs, alongside Mutare's built-ins:

# .mutare.exs
[mutators: [:builtins] ++ Mutare.Decimal.all()]

This configuration keeps Mutare's default families and adds the Decimal ones. Each Decimal family is reported separately and can be enabled on its own:

[mutators: [:builtins, Mutare.Decimal.Comparison]] # just the comparison swaps

Then run Mutare as usual:

mix mutare

The families

Mutare.Decimal.all/0 returns eleven mutator families:

Family Name Mutation
Mutare.Decimal.Arithmetic :decimal_arithmetic swaps same-arity arithmetic calls (add/sub/mult/div/div_int/rem) and reverses the operands of the non-commutative ones
Mutare.Decimal.Comparison :decimal_comparison swaps comparison predicates (gt?/gte?/lt?/lte?), swaps min/max, and reverses operands of compare/2,3 and the deprecated cmp/2
Mutare.Decimal.Classification :decimal_classification swaps classification predicates (positive? ↔ negative?, nan? ↔ inf?)
Mutare.Decimal.ZeroBoundary :decimal_zero_boundary replaces positive?/negative? with the inclusive zero-boundary comparison (gte?/lte? against zero), testing whether zero is handled distinctly
Mutare.Decimal.Sign :decimal_sign swaps abs/1 ↔ negate/1 and flips literal signs in Decimal.new/3
Mutare.Decimal.DivRem :decimal_div_rem rebuilds div_rem/2 with the quotient/remainder pair in the wrong tuple order
Mutare.Decimal.Transform :decimal_transform removes value-transforming calls (normalize, apply_context, abs, negate, sqrt, round/1,2,3), collapsing to the argument
Mutare.Decimal.ModeSwap :decimal_mode_swap swaps round/3 rounding modes and to_string/2,3 output formats for another valid mode or format
Mutare.Decimal.DefaultDrop :decimal_default_drop drops refinement/default arguments (round/2,3, compare/3, eq?/3), falling back to Decimal's defaults
Mutare.Decimal.LimitsDrop :decimal_limits_drop drops parsing/rendering limit arguments (parse/2, cast/2, new/2, to_string/2,3)
Mutare.Decimal.Context :decimal_context removes Decimal.Context.with/set/update effects

The mutators match calls written qualified (Decimal.add(a, b)), aliased, imported, or as a pipe stage; a pipe stage gets the same mutants as the direct call it stands for (a |> Decimal.sub(b) is reversed like Decimal.sub(a, b)). Replacements use valid Decimal operations. To avoid duplicate mutants across these families and Mutare's built-ins, the mutation rules exclude swaps equivalent to another family's argument drop and drops equivalent to a core literal mutation. Each family's module docs describe these exclusions.

Ignoring one kind of mutant

Every family declares ignore-variant labels — the target operation, target mode, dropped argument, or operation kind — so a # mutare:ignore[family:label] directive can suppress one kind of mutant while retaining the family's other mutations:

Decimal.mult(price, tax) # mutare:ignore[decimal_arithmetic:div]
Decimal.compare(left, right) # mutare:ignore[decimal_comparison:operands]
Decimal.parse(input, opts) # mutare:ignore[decimal_limits_drop:parse_options]
Decimal.round(total, 2, :half_up) # mutare:ignore[decimal_mode_swap:half_even]
Decimal.positive?(amount) # mutare:ignore[decimal_zero_boundary:gte?]
Decimal.normalize(amount) # mutare:ignore[decimal_transform:normalize]
Decimal.negate(amount) # mutare:ignore[decimal_sign:abs]
Decimal.Context.set(context) # mutare:ignore[decimal_context:set]

A bare # mutare:ignore[decimal_arithmetic] suppresses the whole family on that line. See Mutare.Ignore for the directive grammar.

Supported versions

Decimal >= 2.2.0 and < 4.0.0. Matching is syntactic, so calls that only exist in one line (e.g. to_string/3) simply never occur in codebases on the other — every call the mutators emit exists across the whole range.

Development

mix deps.get
mix test # unit diffs + cross-family duplicate checks + variant-label contract
mix check # format + credo + dialyzer

License

MIT — see LICENSE.