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

Email code has a signature test gap: the suite asserts an email was sent, and nothing more. The wrong recipient class (cc where bcc protects privacy), a reply-to that quietly falls back to from, a dropped attachment, a provider template id nobody checks — or a deliver that never happens — all pass such a test. mutare_swoosh mints well-formed-but-wrong Swoosh programs at exactly those spots, so a surviving mutant points at the precise assertion your suite is missing.

Install

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

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

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

Enable

List the families in .mutare.exs, alongside Mutare's built-ins — naming your mailer activates the delivery family:

# .mutare.exs
[mutators: [:builtins] ++ Mutare.Swoosh.all(mailer: MyApp.Mailer)]

:builtins keeps Mutare's default families and adds the Swoosh ones. mailer: takes one module or a list; without it (Mutare.Swoosh.all()) the :swoosh_deliver family stays inert and everything else still runs. Every listed mailer records under the one swoosh_deliver name — to report mailers separately, list the family twice with as: ({Mutare.Swoosh.Deliver, mailer: MyApp.AdminMailer, as: :swoosh_deliver_admin}). Each family records under its own report name and can be enabled on its own:

[mutators: [:builtins, Mutare.Swoosh.Recipient]]   # just the recipient mutations

Then run Mutare as usual:

mix mutare

The families

FamilyNameMutationThe gap a survivor exposes
Mutare.Swoosh.Deliver:swoosh_deliverreplaces Mailer.deliver/1,2 with a non-delivering {:ok, %{}} (deliver!/1,2%{}) on the configured mailer(s)no test asserts the email was actually sentassert_email_sent
Mutare.Swoosh.Recipient:swoosh_recipientswaps recipient classes (to/cc/bcc; put_to/put_cc/put_bcc), deletes one recipient from literal values, and weakens put_* → plain (replace became append)no test pins who the email goes to — the ccbcc swap is the privacy question
Mutare.Swoosh.Sender:swoosh_senderswaps fromreply_to; drops reply_toreplies silently go to from; no test asserts the reply-to address
Mutare.Swoosh.Subject:swoosh_subjectblanks the subject to ""no test reads the subject
Mutare.Swoosh.Body:swoosh_bodyswaps html_bodytext_bodyno test distinguishes which body part carries the content
Mutare.Swoosh.Header:swoosh_headerremoves header/3; drops entries from a headers: optionno test asserts the custom header
Mutare.Swoosh.Attachment:swoosh_attachmentswaps disposition (type: :inline:attachment); removes the attachment entirelyno test asserts the attachment exists, or how it displays
Mutare.Swoosh.ProviderOption:swoosh_provider_optionremoves put_provider_option/3no test asserts the provider option — template ids and dynamic template data live here

Every email-field family mutates both the pipeline call (email |> subject("Welcome")) and the matching Swoosh.Email.new/1 option (Email.new(subject: "Welcome")). Calls match written qualified, aliased, imported, or as a pipe stage, and every replacement is itself a valid Swoosh program — a survivor means a missing assertion, not a crash.

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 |> bcc(auditors)              # mutare:ignore[swoosh_recipient:cc] audit copy may be visible
email |> put_to(recipients)         # mutare:ignore[swoosh_recipient:append]
Mailer.deliver!(email)              # mutare:ignore[swoosh_deliver:deliver!]
email |> attachment(invoice_pdf)    # mutare:ignore[swoosh_attachment:delete]

The put_* → plain weakening (append) is deliberately one-directional: writing put_to declares you care about replacement semantics, so that intent should be tested; writing plain to declares no such intent, so there is no reverse mutant. It is also the one mutant that can be legitimately equivalent — when no recipients were set yet, replace and append coincide — so each carries a report note saying a kill may require pre-existing recipients.

What's deliberately out of scope

  • from removal — Swoosh validates the sender at delivery, so the mutant would only crash: an uninformative kill, not a test-quality signal.
  • Template renderingrender_body/3 and friends belong to phoenix_swoosh, and are covered by the companion mutare_phoenix_swoosh package.

Development

The test suite runs against stand-in Swoosh.Email/Swoosh.Attachment modules (test/support/swoosh_stubs.ex) that mirror the real API — kept faithful by hand, since a real :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.