Mutare Phoenix
Custom Mutare mutators for the Phoenix server-side surface —
the Phoenix.Controller calls a controller action performs on the conn, the Phoenix.Channel
replies and outbound messages, Phoenix.PubSub, and Phoenix.Token — plus macro routing
that excludes Phoenix's compile-time macros (the Phoenix.Router DSL, ~H) from mutation
to prevent metamutant compilation failures.
A controller action returns a transformed conn, so its whole contract is which
conn-transforming call ran — the status it set, where it redirected, whether it halted.
These are exactly the calls a suite tends to under-assert: a test that checks "something
happened" but not which transformation leaves a gap. A channel has the same shape of gap
on its outbound side — the reply a join/handle_in returns, the message it broadcasts or
pushes, the PubSub topic it subscribes to — where a test that only checks the socket came
back leaves the client-facing effect unasserted. mutare_phoenix turns each such gap into a
located Mutare survivor.
It builds on mutare_plug (the Plug.Conn
families — halt, status, session, header, cookie, body) the way phoenix builds on plug:
it depends on it, so those families are on your code path too, ready to compose.
The families
Mutare.Phoenix.all/0 returns three Phoenix.Controller families, two Phoenix.Channel
ones, a Phoenix.PubSub one, and a Phoenix.Token one:
| Family | Name | Mutation | The gap a survivor exposes |
|---|---|---|---|
Mutare.Phoenix.Redirect |
:redirect_status |
swaps the explicit atom status: option of Phoenix.Controller.redirect/2 for a redirect-status sibling (:found → :see_other, :moved_permanently → :permanent_redirect) |
no test pins the exact redirect status |
Mutare.Phoenix.Body |
:controller_body |
blanks the body argument of Phoenix.Controller.json/2 to %{} and of text/2 / html/2 to "" |
no test reads the rendered body |
Mutare.Phoenix.Download |
:download_disposition |
flips the explicit disposition: option of Phoenix.Controller.send_download/3 between :attachment and :inline |
no test checks whether the response specifies saving or displaying the file |
Mutare.Phoenix.ChannelReply |
:channel_reply |
drops the reply element of a Phoenix.Channel callback return: {:ok, reply, socket} → {:ok, socket}, {:reply, reply, socket} → {:noreply, socket}, {:stop, reason, reply, socket} → {:stop, reason, socket} (gated on @behaviour Phoenix.Channel) |
no test checks the join reply or assert_replys the handle_in reply |
Mutare.Phoenix.ChannelMessage |
:channel_message |
removes a Phoenix.Channel outbound message — broadcast/3 and its !/_from siblings, push/3, reply/2 — collapsing the call to :ok (variants broadcast, push, reply) |
no test assert_broadcasts / assert_pushes / assert_replys the message |
Mutare.Phoenix.PubSub |
:pubsub |
removes a Phoenix.PubSub subscribe, unsubscribe, or broadcast call (every broadcast/broadcast_from/local_broadcast/direct_broadcast form), collapsing it to :ok (variants subscribe, unsubscribe, broadcast) |
no test delivers a message on the topic and checks the subscriber reacted, or asserts a broadcast arrived |
Mutare.Phoenix.Token |
:token |
swaps a Phoenix.Token call for its sibling scheme (sign ↔ encrypt, verify ↔ decrypt; variant scheme), blanks a sign/encrypt payload to nil (payload), and turns an explicit integer max_age: of verify/decrypt into :infinity (expiry, with the position marked :timeout so the built-in integer family skips the duration literal) |
no test round-trips the token, checks its payload, or passes an expired token to verify/decrypt |
Each call family matches its call written directly (Phoenix.Controller.redirect(conn, ...)),
aliased, or bare-imported (redirect(conn, ...), the form use MyAppWeb, :controller
produces; broadcast(socket, ...), the form use Phoenix.Channel produces). The option
families only mutate an explicit literal atom: redirects and downloads that rely on Phoenix's
default, integer statuses, and variable values are left to other families or skipped.
render/3 is out of scope for :controller_body — its argument names a template, not a
body.
The removal families (:channel_message, :pubsub) collapse a whole call to its success
value, :ok, and only at the call's defined arities, so every generated mutant still
compiles; a call written as a pipe stage is collapsed over the whole pipe. The
families that produce several kinds declare variant labels, so a qualified
# mutare:ignore[pubsub:subscribe] silences just one kind at a site.
The Plug.Conn side of a controller action — put_status, send_resp, put_session,
put_resp_header, put_resp_cookie, halt — is the base package's six families,
Mutare.Plug.all/0.
The :extensions entry
Mutare.Phoenix is also a Mutare.CallRouting extension. Listed under :extensions, it
routes Phoenix's compile-time-only macro calls :skip — each call is an inert leaf, so
neither its arguments nor the call itself is ever mutated: the Phoenix.Router DSL (get/post/scope/…), because route definitions run
once at compile time under Mutare's compile-once model and a mutation there could never
activate; and Phoenix.Component.sigil_H/2 (~H), because HEEx sigil arguments must remain
compile-time literals — left unregistered, Mutare's imported-call witness would splice an
unreachable sigil_H(arg1, arg2) that Phoenix rejects at compile time, failing the whole
metamutant build. Mutations around a ~H expression, such as a render/1 return-value
mutant, remain available.
Usage
mutare_phoenix uses the Mutare engine and depends on
mutare_plug, so add them as :dev/:test dependencies:
# mix.exs
defp deps do
[
{:mutare, "~> 0.4.1", only: [:dev, :test], runtime: false},
{:mutare_plug, "~> 0.2", only: [:dev, :test], runtime: false},
{:mutare_phoenix, "~> 0.3", only: [:dev, :test], runtime: false}
]
end
Then list the families in .mutare.exs, and Mutare.Phoenix under :extensions. Setting
:mutators replaces Mutare's default set, so include the :builtins family to keep the
built-ins on:
# .mutare.exs
[
mutators: [:builtins] ++ Mutare.Plug.all() ++ Mutare.Phoenix.all(),
extensions: [Mutare.Phoenix]
]
all/0 returns only this package's families — it does not include the mutare_plug
ones, so compose Mutare.Plug.all/0 explicitly as shown. Run it the usual way:
mix mutare
Why not the built-in atom mutators?
In a status position, Mutare's built-in atom swaps (:ok → :error / :mutare) produce a
value that crashes — a kill that does not indicate whether tests check the status.
:redirect_status and :download_disposition swap to valid siblings (Phoenix rejects any
disposition other than :attachment / :inline), so a survivor means a genuine missing
assertion rather than a crash, and Mutare's overlap pruning drops the redundant crashing
leaves at the same range. :controller_body works the same way for a literal text / html
body: its whole-call blank covers the string node, so the built-in string family's sentinel
leaves there are pruned automatically and one clean "is the body read?" mutant remains.
Example
examples/demo is
a standalone mini-project with an auth plug, controller actions, a room channel,
PubSub notifications, and invitation tokens over small framework stand-ins. Deliberate
test gaps surface survivors in all seven Phoenix families, plus Plug halt and status.
From the repo root:
mix compile
mix mutare examples/demo
Like the companion packages' examples, the demo has a deliberately partial test suite. Its walkthrough explains each survivor and the assertion that closes the gap.
Scope
The Plug.Conn families are defined in the base
mutare_plug; LiveView is the companion
mutare_phoenix_live_view, which builds on this package. Compose Mutare.Plug.all/0
alongside Mutare.Phoenix.all/0 (see "Usage") for the full conn + controller surface.
License
MIT — see LICENSE.