mutare_phoenix_swoosh

Mutation-testing mutators for the phoenix_swoosh template-rendering surface — the layer Phoenix.Swoosh adds on top of a Swoosh mailer — built as a plugin for Mutare.

Template-rendered email has its own signature test gap: the suite asserts the email was sent, and nothing more. A render_body that never ran, a .text template that broke while the .html part kept passing (or the reverse), a branded layout that quietly stopped wrapping the body — all pass such a test. mutare_phoenix_swoosh mints well-formed-but-wrong mailer programs at exactly those spots, so a surviving mutant points at the precise assertion your suite is missing.

It builds onmutare_swoosh (the base email-construction and delivery families) the way phoenix_swoosh builds on swoosh: it depends on it, so those families are on your code path too, ready to compose.

Install

Add it (with Mutare and the base package) as dev/test dependencies:

def deps do
[
{:mutare, "~> 0.1", only: [:dev, :test], runtime: false},
{:mutare_swoosh, "~> 0.1", only: [:dev, :test], runtime: false},
{:mutare_phoenix_swoosh, "~> 0.1", only: [:dev, :test], runtime: false}
]
end

(Neither phoenix_swoosh nor swoosh is a dependency of this plugin — the mutators match calls purely syntactically. Your own project already supplies them.)

Enable

Splice the families into .mutare.exs alongside Mutare's built-ins and the mutare_swoosh preset, and list Mutare.Phoenix.Swoosh under :extensions (see "Why the :extensions entry" below):

# .mutare.exs — a phoenix_swoosh mailer app
[
mutators:
[:builtins] ++
Mutare.Swoosh.all(mailer: MyApp.Mailer) ++
Mutare.Phoenix.Swoosh.all(),
extensions: [Mutare.Phoenix.Swoosh]
]

Mutare.Phoenix.Swoosh.all/0 returns only this package's two families — it does not include the base mutare_swoosh families, so compose Mutare.Swoosh.all/1 explicitly as shown for the full Swoosh + template surface. Then run Mutare as usual:

mix mutare

The families

FamilyNameMutationThe gap a survivor exposes
Mutare.Phoenix.Swoosh.RenderBody:render_bodyremoves a render_body/2,3 call (remove — the email ships with no rendered body); narrows a literal atom template to one of its string forms (html_only / text_only — only that body part renders); drops one entry from a literal put_new_formats/2 map (format — that extension stops rendering)no test asserts the rendered body — or asserts only one of the body parts, the classic "the text part broke and nobody noticed"
Mutare.Phoenix.Swoosh.Layout:mail_layoutremoves put_layout/2 (put) / put_new_layout/2 (put_new), collapsing to the email; sets the layout: assign of a render_body call to false (off — the body renders with no layout at all)no test asserts which layout wrapped the rendered body

Each family matches its call written qualified (Phoenix.Swoosh.render_body(...)), aliased, bare-imported (the form use Phoenix.Swoosh produces — the wrapper's default-assigns render_body/2 included), or as a pipe stage, and every replacement is itself a valid phoenix_swoosh program — a survivor means a missing assertion, not a crash.

Both families also pin phoenix_swoosh's structural argument positions against Mutare's built-in value families: the template name, the layout tuple (interior included), and the put_new_formats/2 map. A perturbed template or layout is a missing-template crash at render time — an uninformative kill, never a test-quality signal — so core's literal families never mint mutants there. Keeping a position raw for everyone and then minting the one safe mutation there from its owner is what the format drop does: the map's keys stay untouchable, and dropping a whole entry is the one rewrite that means something.

Why :mail_layout reaches into the render call

Idiomatic mailers rarely call put_layout at all — the layout goes on the use line (use Phoenix.Swoosh, view: MyApp.EmailView, layout: {MyApp.LayoutView, :email}), which is compile-time configuration no mutant can be delivered into (see "What's deliberately out of scope"). The off mutant reaches the same layout through the seam it actually flows through at runtime: render_body's assigns, where phoenix_swoosh looks for a per-render override before falling back to the email's layout.

Suppressing a layout that was never in effect would be an equivalent mutant — unkillable, and this package does not mint those — so off fires only where a layout demonstrably is in effect:

A mailer that configures its layout by callingput_layout gets the put removal at that call instead — the same gap, owned once.

Ignoring one kind of mutant

The families declare ignore-variant labels, so a # mutare:ignore[family:label] directive can suppress one kind of mutant without silencing the whole family:

email |> render_body(:welcome, assigns) # mutare:ignore[render_body:text_only] no text part shipped
email |> render_body(:welcome, assigns) # mutare:ignore[mail_layout:off] layout asserted elsewhere
email |> put_layout({LayoutView, :email}) # mutare:ignore[mail_layout:put]
email |> put_new_formats(@formats) # mutare:ignore[render_body:format]

Why the :extensions entry

use Phoenix.Swoosh does not surface render_body the way most uses surface their API: its __using__ injects import Phoenix.Swoosh, except: [render_body: 3] plus a localdef render_body(email, template, assigns \\ %{}) wrapper. The bare render_body calls a mailer module writes therefore resolve to a hidden local definition — invisible to Mutare's (otherwise faithful) in-process use expansion.

Mutare.Phoenix.Swoosh is therefore also a Mutare.UseExpansion extension: listed under :extensions, it takes over use Phoenix.Swoosh and surfaces a wholeimport Phoenix.Swoosh standing in for the injected wrapper (which forwards to Phoenix.Swoosh.render_body/3 anyway). With it, bare render_body calls — both wrapper arities — resolve, mutate, and get their template position pinned.

The extension also reads one fact out of the use line — whether the mailer configured a layout — and reports it to :mail_layout as the Mutare.Phoenix.Swoosh.LayoutConfigured marker. Mutare hands a use's injected behaviours to mutators as context.behaviours; that is the one channel a use expansion has into a mutator, and it is how a compile-time option gates a runtime mutant. The marker never reaches the metamutant, the compiled program, or the report.

Without the :extensions entry the families still work on qualified and aliased calls, and :mail_layout works on bare setter calls too (the layout setters really are imported) — but bare render_body sites are neither mutated nor pinned, and off fires only where the author wrote the layout: assign themselves.

What's deliberately out of scope

Tuning core's families at the assigns position

The assigns argument stays ordinary runtime data, so core's value families mutate the values inside it — that is the point: a wrong interpolated value in a body is caught only by a body-content assertion. One of core's mutants there is a crash-kill rather than a signal, though: collapsing the whole assigns map to %{} kills itself on the first @assign the template reads. Route that one position :interior per-project if the noise bothers you — the map's own node is never offered, while everything inside it still mutates:

# .mutare.exs
[
mutators: [:builtins] ++ Mutare.Swoosh.all(mailer: MyApp.Mailer) ++ Mutare.Phoenix.Swoosh.all(),
extensions: [Mutare.Phoenix.Swoosh],
call_routes: [{Phoenix.Swoosh, :render_body, 3, [:expression, :expression, :interior]}]
]

That covers the assigns map itself. It does not reach values nested inside it — :interior spares only the argument's own node, so core's alias and atom families still perturb a layout: {LayoutView, :email} assign into missing-template crash-kills. The package pins the layout only where it is a whole argument (put_layout/2, put_new_layout/2); inside the assigns map the honest tools are # mutare:ignore at the site, or leaving it be.

Development

The test suite runs against stand-in Phoenix.Swoosh/Swoosh.Email modules (test/support/phoenix_swoosh_stubs.ex) that mirror the real API — the __using__ with its except: import and local wrapper included — kept faithful by hand, since a real :phoenix_swoosh test dep would collide with them.

mix deps.get
mix test # unit diffs + live semantic checks that a mutant actually changes behaviour
mix check # format + credo + dialyzer

License

MIT — see LICENSE.