mutare_ecto

A mutation-testing plugin for Ecto, built as a custom Mutare mutator.

Mutation testing checks how good your tests actually are: it makes small, deliberate changes to your code — a > becomes a >=, a where clause is dropped, a validate_required is removed — and reruns your suite. If the tests still pass, that mutation survived, and you've found a gap your assertions don't cover.

mutare_ecto aims that lens at the Ecto code you write — Repo calls, changeset pipelines, and the from/query DSL — so a survivor tells you something concrete: "no test would notice if this filter, this sort order, or this validation quietly changed."

Why a dedicated Ecto mutator

An Ecto where clause looks like Elixir, but it isn't — it's a fragment of SQL, and SQL runs under three-valued logic (NULL is neither true nor false). A general-purpose mutator that treats a < b or a > b as ordinary Elixir will "helpfully" conclude it's equivalent to a != b and skip the mutation. In SQL that's wrong: when a or b is NULL, the two differ — and that's exactly the untested edge you'd want flagged.

So mutare_ecto ships its own SQL-semantics mutation catalog and never borrows Mutare's Elixir-semantics mutators inside a query. Every mutation it emits is one a real SQL engine will run, and its equivalence reasoning is SQL's, not Elixir's.

Installation

Add both Mutare and this plugin to the app you want to test, in :dev/:test:

# mix.exs
defp deps do
[
{:mutare, "~> 0.1"},
{:mutare_ecto, "~> 0.1"}
]
end

It must run as a dependency of the app under test (not against an external source path), so your Repo and schemas are loadable in the Mutare process — that's what lets use Ecto.Schema expand and the query macros resolve. External-source operation is unsupported: the plugin declares the Ecto surface it needs (Ecto.Schema/Ecto.Query) via Mutare.Ecto.required_modules/0, and Mutare checks it once at startup, aborting with a Mutare.EnvironmentError when a module is not loadable. Beyond that guard, unresolved target-app modules can still make routing incomplete or invalid.

Usage

Enable it in .mutare.exs, naming your Repo when Repo-call mutations are needed:

# .mutare.exs
[
mutators: [
:all, # Mutare's built-ins for ordinary Elixir
{Mutare.Ecto, repo: MyApp.Repo} # the Ecto surface
]
]

Then run Mutare as usual. Listing the entry both registers the plugin's query-DSL routing and enables its mutations; repo: (one module, or a list when the app has several) is optional for query and changeset mutations, and is what lets it recognise Repo.* calls regardless of how they're aliased or imported.

What it mutates

Every mutation is tagged with a family, so you can enable or report on them individually (see Configuration). They cover three surfaces:

Inside where / having conditions — delivered through Ecto's ^/dynamic injection so the query still compiles once and the active mutant is chosen at build time:

FamilyExampleQuestion a survivor raises
comparisonu.age > 18>= 18Is the boundary tested?
null_predicateis_nil(u.x)not is_nil(u.x)Is the NULL case tested?
connectivea and ba or bDoes any row distinguish the two?
membershipx in ^listx not in ^list; exists(…)not exists(…); x in [a, b]x in [b]; likeilikePolarity / set membership / case-sensitivity
arithmeticu.a + u.bu.a - u.b; */ (also in select/order_by values)Does the computed value matter?
coalescecoalesce(u.x, 0)u.x (also in select/order_by values)Is the NULL fallback exercised?
temporalago(3, "day")from_now(3, "day")Does a row near now pin the direction?
integer_literalu.age > 1819 / 17 / 0Off-by-one in an integer literal
float_literalu.score > 2.53.5 / 1.5 / 0.0Off-by-one in a float literal
string_literalu.name == "ok""" / "mutare"Is the string value tested?
atom_literalu.status == :active:mutareIs the atom value tested?
boolean_literal… and true… and falseIs the boolean operand tested?
binding_reorder[a, b][b, a]Does their declared order matter?
filter_dropdrop a whole where/having clauseIs this filter tested at all?

Off by default (opt-in). A string, atom, or boolean literal mutant is the most likely to be a noisy survivor — a string/atom because its value space is large (an in-fragment string the broadest), a boolean because a direct boolean literal in a condition is rarely idiomatic. Enable them with families: :all or by naming them in an explicit list (see Configuration). The numeric arms (integer_literal/float_literal) are on by default. Whatever the selection, a literal at a structural position of a known Ecto DSL form — the fragment template, the interval unit of datetime_add/date_add/from_now/ago, the cast type of type/2, the name in field/2, as/1/parent_as/1, or selected_as — is never mutated (it shapes the SQL, so a mutant would just be a broken query, not a test signal).

Query shape — ordering, pagination, joins, aggregates, and the query terminals:

FamilyExample
orderingorder_by: [asc: u.name][desc: u.name]
ordering_nulls:asc_nulls_first:asc_nulls_last
boundlimit: 109 / 11, or drop the limit/offset
join_typeleft_joininner_join, full_joinleft_join/right_join (narrows cardinality)
combinationintersectexcept, intersect_allexcept_all (union is left alone)
aggregatesum(u.x)avg(u.x), minmax (in select/order_by/having, or Repo.aggregate)
clause_dropdrop a standalone/pipe stage — q |> group_by(…), |> select(…), |> join(…), … → q (never order_by: an unordered result has no defined order to test)
query_terminalEcto.Query.firstlast

Repo writes and changesets — plain calls, no query DSL involved:

FamilyExampleQuestion a survivor raises
persistenceRepo.insert(cs) → non-persisting apply_actionDoes a test assert the write actually happened?
on_conflictswap on_conflict: on insert/insert!/insert_all:nothing:raise, :raise:nothing, :replace_all:nothingIs the conflict behaviour tested?
validation_dropdrop validate_required, unique_constraint, …Is the rule it enforces tested?
hook_dropdrop prepare_changes / optimistic_lockIs the side effect / lock asserted?

Both query syntaxes are covered — the from(u in User, where: …) keyword form (piped too: User |> from(as: :u, where: …)) and the composable pipe form (q |> where([u], …)) — as are direct, aliased, and import/use-bundled call styles. Schema definitions (schema/embedded_schema) are left untouched: a mutated field name is a broken schema, not an interesting mutant.

Configuration

Each {Mutare.Ecto, …} entry takes:

{Mutare.Ecto,
repo: MyApp.Repo, # optional — identifies Repo.* calls; a module or a list
families: :default, # the default; or :all, a list, or {:default | :all, except: […]}
dialects: [:postgres]} # gate dialect-specific mutations (default: portable core)

Unknown plugin option names raise an ArgumentError; as: is handled and removed by Mutare before the remaining options reach this plugin.

Equivalence reporting

Some survivors are honest signal rather than a flat "your test is missing." A surviving >=>, andor, or is_nil mutant may be legitimately unkillable without the right fixture — a boundary row, a NULL, an orphan — not an oversight. The plugin marks these families and gives each a note naming the specific data a kill needs, so the report reads:

… SURVIVED — kill may require a row whose value sits exactly on the bound — …
… SURVIVED — kill may require NULL rows in the ordered column — …
… SURVIVED — kill may require an orphan row — …

The reasons are distinct — a boundary value, NULL exclusion (==/!=), three-valued and/or, an arithmetic identity operand (0 for +/-, ±1 for *//), a NULL row for the coalesce default, a near-now row for the ago/from_now flip, NULL ordering, join cardinality — so the notes are too, rather than one catch-all string.

Mutare.Ecto.equivalence_sensitive_families/0 returns that set, and with as: you can group them under their own report name to separate "needs a boundary fixture" from "needs any test at all":

# .mutare.exs
[
mutators: [
:all,
{Mutare.Ecto, repo: MyApp.Repo, dialects: [:postgres],
families: Mutare.Ecto.equivalence_sensitive_families(), as: :ecto_boundary_null},
{Mutare.Ecto, repo: MyApp.Repo, dialects: [:postgres]}
]
]

Because the catalog is SQL-native, it also never emits the always-equivalent mutations (like x * 1) that would otherwise inflate your denominator and dilute the score.