Mutare Phoenix

Hex.pm Hexdocs CI License

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 the defensive macro routing that keeps Phoenix's compile-time macros (the Phoenix.Router DSL, ~H) from poisoning the metamutant build.

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 pins whether the browser is told to save or display 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 leaves the duration literal alone) no test round-trips the token, checks the payload it carries, or presents an expired one

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 the :ok its happy path returns, and only at the call's real arities, so every generated mutant still compiles; a piped form is left alone, since none of these calls returns its receiver. 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, sinking the whole metamutant build. Mutations around a ~H expression, such as a render/1 return-value mutant, remain available.

Usage

mutare_phoenix rides on the Mutare engine and builds on mutare_plug, so add them as :dev/:test dependencies:

# mix.exs
defp deps do
[
{:mutare, "~> 0.1", only: [:dev, :test], runtime: false},
{:mutare_plug, "~> 0.1", only: [:dev, :test], runtime: false},
{:mutare_phoenix, "~> 0.2", 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 — an uninformative kill that tells you nothing about test quality. :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 keeps its test suite deliberately partial. Its walkthrough explains each survivor and the assertion that closes the gap.

Scope

The Plug.Conn families live 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.