One Pipe, One User Story: The Case for phoenix_test
Posted on
While working on LocalCents, I had the opportunity to experiment with a lot of different tech, and one library that really impressed me was phoenix_test. No better way to explain it than walking through the code.
Here is a sample test from LocalCents:
test "adding an expense through the editor lists it", ~M{conn} do
{:ok, book} = Tracking.create_book("Family Expenses")
conn
|> visit(~p"/books/#{book.id}")
|> click_button("New Expense")
|> within("#expense-editor", fn editor ->
editor
|> fill_in("Date", with: "2026-06-10")
|> fill_in("Description", with: "Coffee")
|> fill_in("Cost", with: "4.75")
|> click_button("Create")
end)
|> assert_has("#expenses", text: "Coffee")
|> assert_has("#expenses", text: "$4.75")
end
And here is an alternative using standard tooling:
test "(alt) adding an expense through the editor lists it", %{conn: conn} do
{:ok, book} = Tracking.create_book("Family Expenses")
{:ok, view, _html} = live(conn, ~p"/books/#{book.id}")
view
|> element("button", "New Expense")
|> render_click()
view
|> form("#expense-form",
expense: %{date: "2026-06-10", description: "Coffee", cost: "4.75"}
)
|> render_submit()
assert has_element?(view, "#expenses", "Coffee")
assert has_element?(view, "#expenses", "$4.75")
end
Some of the reasons I prefer the phoenix_test style:
- I get to express the entire event chain as a single pipe. Every
phoenix_teststep takes the session and returns the session, so the chain never breaks. In the standard version,render_clickandrender_submitreturn rendered HTML, not the view, so each interaction is a dead end and you have to start a fresh pipe fromview. - The function names describe what the user sees, like a form input labeled
Date, instead of asking me to know the DOM IDs. - The default tooling has you build the form payload, and that is a poor choice for two reasons.
- One, default tooling creates false confidence. If the submit button or field is removed or renamed, a test with a manually constructed payload still passes.
- Two, default tooling requires the test to have implementation knowledge it should not possess. In general, tests should validate the API (in this case the web presentation) and avoid assumptions about implementation.
- I love how the
withinblock looks as an inner pipe. It feels like a natural way to say “do something, and then do something on this new thing.” It also disambiguates: when two buttons share the labelDelete, scoping to#delete-expense-modalpicks the right one. - The library description says it “handles navigation between LiveView and static pages seamlessly. So, you don’t have to worry about what type of page you’re visiting. Just write the tests from the user’s perspective.” I didn’t lean on that much with LocalCents, but for long integration flows, I’m sure it comes in handy.
When has_element?/3 fails, you get:
1) test full editor (alt) adding an expense through the editor lists it (LocalCentsWeb.BookLiveTest)
test/local_cents_web/live/book_live_test.exs:131
Expected truthy, got false
code: assert has_element?(view, "#expenses", "missing")
arguments:
# 1
#Phoenix.LiveViewTest.View<id: "phx-GNHb1OqVudpb1gyB", module: LocalCentsWeb.BookLive, pid: #PID<0.608.0>, endpoint: LocalCentsWeb.Endpoint, ...>
# 2
"#expenses"
# 3
"missing"
stacktrace:
test/local_cents_web/live/book_live_test.exs:146: (test)
If you were using a (sadly) common assertion like…
assert render(view) =~ "missing"
…you’d get the entire page’s HTML posted to the console.
When assert_has/3 fails, by contrast, you get the more useful:
1) test full editor adding an expense through the editor lists it (LocalCentsWeb.BookLiveTest)
test/local_cents_web/live/book_live_test.exs:150
Could not find any elements with selector "#expenses" and text "missing"
Found these elements matching the selector "#expenses":
<div id="expenses" class="flex min-h-0 flex-1 flex-col"><div class="m-4 bg-white rounded-lg overflow-hidden border border-surface-200 shadow-md shadow-primary-500/20 flex min-h-0 flex-1 flex-col"><div class="overflow-y-auto divide-y divide-surface-200/60 flex-1 [&>*:last-child]:border-b [&>*:last-child]:border-surface-200/60" style=""><button type="button" class="flex w-full items-center gap-4 px-4 py-3 bond-ink-hover-row transition-colors text-left cursor-pointer" style="--bond-ink: var(--color-primary-800)" id="expense-39b290db-097b-4ca7-b7a0-0f19be58d0c6" phx-click="edit_expense" phx-value-id="39b290db-097b-4ca7-b7a0-0f19be58d0c6"><span class="shrink-0 text-sm tabular-nums w-24 text-surface-600">
06/10/2026
</span><span class="flex-1 text-sm font-medium text-surface-800">
Coffee
</span><div class="flex items-center gap-1.5"></div><span class="shrink-0 text-sm font-bold tabular-nums w-16 text-right text-success-600">
$4.75
</span></button></div></div></div>
code: |> assert_has("#expenses", text: "missing")
stacktrace:
(phoenix_test 0.12.1) lib/phoenix_test/assertions.ex:131: PhoenixTest.Assertions.assert_has/3
(phoenix_test 0.12.1) lib/phoenix_test/live_view_timeout.ex:26: PhoenixTest.LiveViewTimeout.handle_watched_messages_with_timeout/4
test/local_cents_web/live/book_live_test.exs:163: (test)
Scanning a focused HTML blob is so much nicer.
Wait, there’s more!
The phoenix_test library also ships with a Credo check: PhoenixTest.Credo.NoOpenBrowser.
The
open_browser/1function is useful during development but should not be committed in tests, as it would open browsers during CI runs, which can cause unexpected behavior and CI failures.A Credo check that disallows the use of open_browser/1 in test code.
Fight on the side of readable code!
In a world that is generating code by the ton, be the voice of sanity and push for more readable code!
Check out phoenix_test as a helpful weapon for the battlefield.
You might also like
About the Author. Mike Zornek is a developer and teacher focusing on product design and development with a heavy focus on Elixir and LiveView. In between his projects, Mike helps other teams through consulting. During off hours, he enjoyed watching Phillies baseball and playing relaxing video games.
Hopefully, you found interest in my scribbles. If you have commentary or a response, I'd love to hear it.