ML-182: document PhoenixTest patterns and migration guide in testing skill

This commit is contained in:
Claudio Ortolina
2026-05-15 07:07:37 +01:00
parent 74b4e2524f
commit 93e414cf15
+249 -9
View File
@@ -52,21 +52,62 @@ mix test --max-failures 5
## Test Styles — When to Use Each ## Test Styles — When to Use Each
### PhoenixTest (`assert_has`, `click_button`, `click_link`, `visit`) ### PhoenixTest (`visit`, `click_button`, `click_link`, `fill_in`, `assert_has`)
Use for **page-level LiveView tests** where you're testing navigation and DOM presence: The **primary framework** for all LiveView page-level tests. Prefer PhoenixTest over
`Phoenix.LiveViewTest.live/2` for all new and migrated tests.
```elixir ```elixir
{:ok, view, _html} = live(conn, ~p"/collection") # Navigation and assertions
assert_has(view, ".record-card") conn
|> visit(~p"/collection")
|> assert_has("h1", "My Collection")
|> click_link("Next Page")
|> assert_path(~p"/collection?page=2")
# Form interactions
conn
|> visit(~p"/record-sets/new")
|> fill_in("Name", with: "Favorites")
|> click_button("Save Set")
|> assert_has("p", "Favorites")
``` ```
### Phoenix.LiveViewTest (`form/3`, `render_submit/1`, `element/2`, `render_click/1`) ### ConnCase Auto-Imports
Use for **LiveComponent interactions** where `phx-target={@myself}` is involved: `MusicLibraryWeb.ConnCase` auto-imports the following LiveViewTest functions (with `only:`):
- `render_async/1`, `render_change/1`, `render_click/3`
- `render_hook/2`, `render_hook/3`
- `element/2`, `element/3`
- `form/3`
It also imports `PhoenixTest` in full. When a test needs LiveViewTest functions NOT in
the auto-import list (e.g. `render_click/1`, `render_submit/1`), add an explicit
`only:` import to the test file rather than importing the whole module.
### The `unwrap/2` Escape Hatch
When PhoenixTest can't handle an interaction directly, use `unwrap/2` to access the
underlying `Phoenix.LiveViewTest.View` and call LiveViewTest functions directly.
`unwrap/2` handles redirects automatically and returns a PhoenixTest session.
```elixir ```elixir
html = render_submit(form(view, "#record-form", record: attrs)) # Trigger a JS hook event
unwrap(session, &render_hook(&1, "reorder", %{"record_ids" => [3, 1, 2]}))
# Submit a form inside a LiveComponent (phx-target={@myself})
unwrap(session, fn view ->
view
|> form("#record-picker-navigation form", %{query: "search term"})
|> render_submit()
end)
# Click a non-button/non-link element with phx-click
unwrap(session, fn view ->
view
|> element("li[phx-click='add_record'][phx-value-record-id='#{id}']")
|> render_click()
end)
``` ```
### Context Tests (standard ExUnit + DataCase) ### Context Tests (standard ExUnit + DataCase)
@@ -163,6 +204,54 @@ Don't duplicate the same assertion across every function that calls the shared c
When multiple routes share the same plug/middleware behaviour (e.g., auth), test it When multiple routes share the same plug/middleware behaviour (e.g., auth), test it
once with a loop or parameterised approach, not N separate identical tests. once with a loop or parameterised approach, not N separate identical tests.
## Test File Migration Checklist (LiveViewTest → PhoenixTest)
When converting a test file from `Phoenix.LiveViewTest.live/2` to `PhoenixTest.visit/2`:
1. **Replace `import Phoenix.LiveViewTest`** with explicit `only:` imports for any
functions still needed (see ConnCase auto-imports above). Common additions:
`render_click: 1`, `render_submit: 1`, `render_change: 1`, `form: 3`.
2. **Replace `live(conn, path)`** with `visit(conn, path)`. The session returned by
`visit` pipes into all PhoenixTest helpers.
3. **Replace `form/3` + `render_submit/1`** with `fill_in/3` + `click_button/2` (for
standard inputs with labels) or `unwrap` with `form/3` + `render_submit/1` (for
forms inside LiveComponents without labeled inputs).
4. **Replace `form/3` + `render_change/1`** with `fill_in/3` (triggers `phx-change`
automatically) or `unwrap` with `form/3` + `render_change/1` (for Fluxon custom
components or label-less inputs).
5. **Replace `element/2` + `render_click/1`** with `click_button/2` (for `<button>`)
or `click_link/2` (for `<a>`), or `unwrap` with `element/2` + `render_click/1`
(for `<li>`, `<span>`, or other non-standard clickable elements).
6. **Replace `render_hook/3`** with `unwrap(&render_hook(&1, event, value))`.
7. **Replace `assert_redirect(view, path)`** with `assert_path(session, path)`.
8. **Replace `has_element?(view, selector)`** with `assert_has(session, selector)` or
`refute_has(session, selector)`.
9. **Replace `render(view)` with substring checks** (`html =~ "text"`) with
`assert_has(session, selector, text)` or `assert_has(session, selector, text: text)`.
10. **For position-based assertions** (e.g., verifying alphabetical order in HTML),
access raw HTML via `Phoenix.LiveViewTest.render(session.view)`.
## Known Blockers for Full PhoenixTest Migration
These patterns currently require LiveViewTest and cannot be fully migrated:
| Pattern | Reason | Workaround |
|---------|--------|------------|
| `send(view.pid, msg)` | Direct process messaging to LiveView | Keep `live/2` for these tests |
| `live_isolated/3` | Testing LiveComponents in isolation | Keep `live_isolated` for these tests |
| `send_update/3` | Updating LiveComponent state directly | Keep `send_update` for these tests |
| `<span phx-click>` (Fluxon badges) | PhoenixTest only supports `<a>` and `<button>` | Refactor to `<button>` or use `unwrap` |
| `<li phx-click>` (custom click targets) | PhoenixTest only supports `<a>` and `<button>` | Use `unwrap` with `element/2` + `render_click/1` |
## Test Organization ## Test Organization
### Tags ### Tags
@@ -205,7 +294,148 @@ test/
Always call through the fixture module — never `Repo.insert` directly. Always call through the fixture module — never `Repo.insert` directly.
## Testing JS Hooks ## PhoenixTest Patterns and Pitfalls
### `assert_has` / `refute_has` Signatures
PhoenixTest provides multiple `assert_has` arities. Use the convenience form for
text matching (no `text:` keyword needed):
```elixir
# Selector only
assert_has(session, "#record-picker-search-input")
# Selector + text (convenience — wraps in text: keyword)
assert_has(session, "h3", "Collected")
assert_has(session, "[data-part='error']", "can't be blank")
# Selector + keyword opts
assert_has(session, "h1", text: "Add Record")
assert_has(session, ".posts", count: 2)
# Selector + text + keyword opts
assert_has(session, "h1", "Hello", count: 2)
```
### Duplicate Button Texts — Scoped Selectors
When a page has multiple buttons with the same visible text (e.g., "Delete" in
dropdown menus), use a scoped CSS selector:
```elixir
click_button(session, "button[phx-click='delete_set']", "Delete")
click_button(session, "#record_1 button[phx-click='delete']", "Delete")
```
### Forms Inside LiveComponent Modals (`phx-target={@myself}`)
PhoenixTest handles forms inside LiveComponents automatically. Fill in fields and
click submit as usual — the `phx-target` routing is handled internally:
```elixir
conn
|> visit(~p"/record-sets/new")
|> fill_in("Name", with: "My Set") # form targets the LiveComponent
|> click_button("Save Set") # submit routes to LiveComponent
|> assert_has("p", "My Set") # verify on index after redirect
```
### Fluxon Custom `<.select>` Components
This project uses Fluxon custom select components (not native `<select>`).
`PhoenixTest.select/3` does NOT work with these — the label's `for` attribute points
to a toggle button, not a `<select>` element.
Use `unwrap` with `form/3` + `render_change/1` instead:
```elixir
defp set_rule_type(session, type) do
unwrap(session, fn view ->
view
|> form("#scrobble_rule-form", scrobble_rule: %{type: type})
|> render_change()
end)
end
```
### `<li>` and `<span>` Elements with `phx-click`
PhoenixTest's `click_button` only supports `<button>`, `[role="button"]`, and
`<input type="button|submit|...">`. `click_link` only supports `<a>`.
For `<li phx-click="...">` or `<span phx-click="...">` elements, use `unwrap` with
`element/2` + `render_click/1`:
```elixir
unwrap(session, fn view ->
view
|> element("li[phx-click='add_record'][phx-value-record-id='#{id}']")
|> render_click()
end)
```
This is a **blocking limitation** — if an interaction relies on clicking non-standard
elements, consider refactoring to a `<button>` with proper accessible text.
### Edit-Form Validation with Existing Data
When testing validation on an edit form, the form is pre-populated with existing
data. Clicking "Save" without clearing fields will NOT trigger "can't be blank"
errors. Use `unwrap` with `render_change` to clear fields first:
```elixir
conn
|> visit(~p"/scrobble-rules/#{rule}/edit")
|> unwrap(fn view ->
view
|> form("#scrobble_rule-form",
scrobble_rule: %{type: "", match_value: "", target_musicbrainz_id: ""}
)
|> render_change()
end)
|> assert_has("*", "can't be blank")
```
### `assert_path` and Patch Links
`assert_path` on `push_patch` links does NOT capture query params from the patch.
Use path-only assertions for patch navigation:
```elixir
# DON'T — query params won't match after a patch
assert_path(session, ~p"/scrobble-rules/#{rule}/edit?#{[page: 1, page_size: 50]}")
# DO — path only, or use query_params for LiveView `handle_params`-visible params
assert_path(session, ~p"/scrobble-rules/#{rule}/edit")
```
For navigation via `push_navigate` or `push_patch` with known query params visible
in the browser URL, `assert_path` with `query_params:` works correctly:
```elixir
|> click_link("a[href*='order=alphabetical']", "A->Z")
|> assert_path(~p"/scrobble-rules", query_params: %{order: "alphabetical"})
```
### Inputs Without Visible Labels
PhoenixTest's `fill_in` requires a `<label>` associated with the input. Inputs with
only a `placeholder` cannot be targeted directly.
For search forms without labels, use `unwrap` with `form/3` + `render_change/1`
to trigger the search handler:
```elixir
defp search_rules(session, query) do
unwrap(session, fn view ->
view
|> form("form[phx-change='search']:not([phx-target])", %{query: query})
|> render_change()
end)
end
```
### Testing JS Hooks
Use `render_hook/3` for testing JS hook interactions: Use `render_hook/3` for testing JS hook interactions:
@@ -215,7 +445,17 @@ assert render_hook(view, "music_library:clipcopy", %{text: "copy me"}) =~ "copy
## Testing `handle_async` ## Testing `handle_async`
Always handle all three cases: For async operations in LiveView tests, use `unwrap(&render_async/1)` which handles
the async resolution and returns a PhoenixTest session:
```elixir
conn
|> visit(~p"/maintenance")
|> unwrap(&render_async/1)
|> assert_has("span", "Connected as alice")
```
For unit testing `handle_async/3` callbacks, always handle all three cases:
```elixir ```elixir
assert {:noreply, socket} = handle_async(socket, :task_ref, {:ok, {:ok, result}}) assert {:noreply, socket} = handle_async(socket, :task_ref, {:ok, {:ok, result}})