Testing
Copy MarkdownFilament's test API mounts a component tree in-process with no browser, no WebSocket, and no Phoenix endpoint. The result is tests that are:
- Isolated — each test owns its component instance; no shared state.
- Fast — typically under 5 ms per test.
- Concurrent — safe to run with
async: true.
Add :floki to your test dependencies (it is not required in production):
# mix.exs
{:floki, "~> 0.38", only: :test}Then import Filament.Test in your test module and you have the full API.
Mounting
view = mount!(MyComponent, %{some_prop: "value"})mount!/3 renders the component, runs any mount effects, and returns a view
struct containing rendered_html, a live fiber_tree, and stubs. It raises if
mounting fails.
Use the non-bang mount/3 when you need to assert on a failure:
assert {:error, reason} = mount(MyComponent, %{bad: :props})Interacting with the view
All interaction helpers follow the same pattern: find an element by CSS selector, dispatch the corresponding event handler, flush state updates, and return the re-rendered view.
Bang variants and pipelines
Every helper has a bang variant that returns the view directly instead of
{:ok, view}. This enables pipelines — the idiomatic style for multi-step
interaction sequences:
view =
mount!(TodoList, %{})
|> submit!("form", %{"text" => "Buy milk"})
|> submit!("form", %{"text" => "Walk the dog"})
|> click!(".todo-list li:first-child input[type=checkbox]")
assert render_text(view) =~ "Buy milk"
assert view.rendered_html =~ ~s(class="completed)The non-bang forms return {:ok, view} | {:error, reason} and are useful when
you need to assert on errors:
assert click(view, "#nonexistent") == {:error, {:no_element, "#nonexistent"}}Helper reference
| Helper | Triggers | Arguments |
|---|---|---|
click!(view, selector) | on_click | CSS selector |
submit!(view, selector, params \\ %{}) | on_submit | selector, form data map |
change!(view, selector, params) | on_change | selector, params map |
blur!(view, selector) | on_blur | selector |
key_down!(view, key, opts \\ []) | on_key (window-level) | key string, optional ctrl: true etc. |
key_down!(view, selector, key) | on_keydown (element-scoped) | selector, key string |
key_down!/3 dispatches to whichever form is appropriate based on the third
argument: a keyword list routes to the window-level on_key handler; a string
routes to the element-scoped on_keydown handler.
Asserting on output
# Plain text content (tags stripped)
assert render_text(view) =~ "Count: 3"
# CSS class presence
assert has_class?(view, ".submit-btn", "disabled")
refute has_class?(view, ".submit-btn", "loading")
# Raw HTML for structure assertions
assert view.rendered_html =~ ~s(data-id="42")has_class?/3 raises if the selector matches no elements, which surfaces
selector typos as loud failures rather than silent false returns.
Keyboard events
on_key is Filament's window-level keyboard binding. Handlers receive the key
string and a %Filament.KeyModifiers{} struct, letting you pattern-match on
both simultaneously:
defcomponent CommandPalette do
def render(_assigns) do
{open, set_open} = use_state(false)
~F"""
<div on_key={fn
"k", %{ctrl: true} -> set_open.(true)
"Escape", _ -> set_open.(false)
_, _ -> :ignore
end}>
{if open do}
<div class="palette">...</div>
{end}
</div>
"""
end
endTesting keyboard interactions is one line per key press:
test "Ctrl+K opens the palette, Escape closes it" do
view =
mount!(CommandPalette, %{})
|> key_down!("k", ctrl: true)
assert render_text(view) =~ "palette"
view = key_down!(view, "Escape")
refute render_text(view) =~ "palette"
endModifier options: ctrl: true, shift: true, alt: true, meta: true.
Unspecified modifiers default to false.
Observable stubs
Components that call use_observable need a server to subscribe to. In tests,
supply a stub instead of a real process:
test "shows item count" do
view =
mount!(CartBadge, %{server: :cart},
stub: [{:cart, fn _req -> %{items: ["a", "b", "c"]} end}]
)
assert render_text(view) =~ "3 items"
endThe stub function receives the subscription request and returns the initial state. Stubs are identified by any term — atoms work well.
Pushing updates
Filament.Test.Stub.push/2 sends a new value to a stub, simulating a server
notify_observers/1 call. Call Filament.Test.update/1 afterward to flush the
message and re-render:
test "re-renders when server state changes" do
{:ok, view} =
mount(CartBadge, %{server: :cart},
stub: [{:cart, fn _req -> %{items: []} end}]
)
assert render_text(view) =~ "0 items"
Filament.Test.Stub.push(view.stubs[:cart], %{items: ["a", "b"]})
view = Filament.Test.update(view)
assert render_text(view) =~ "2 items"
endNote: mount/3 (non-bang) is used here so view.stubs is accessible before
entering the pipeline.
Async assertions with eventually/2
Some state changes happen asynchronously — a spawned process, a delayed
notify_observers, an effect with a timer. eventually/2 retries an assertion
until it passes or a timeout is reached:
test "count updates after async server push" do
{:ok, view} = mount(CartBadge, %{server: :cart},
stub: [{:cart, fn _req -> %{items: []} end}]
)
spawn(fn ->
Process.sleep(50)
Filament.Test.Stub.push(view.stubs[:cart], %{items: ["a"]})
end)
Filament.Test.eventually(fn ->
view = Filament.Test.update(view)
render_text(view) =~ "1 item"
end, timeout: 500)
endOptions: timeout: (ms, default 1000), interval: (retry interval ms,
default 50).
Putting it together — a complete example
defmodule MyApp.CommandPaletteTest do
use ExUnit.Case, async: true
import Filament.Test
alias MyApp.Components.CommandPalette
test "closed on mount" do
view = mount!(CommandPalette, %{})
refute render_text(view) =~ "Search commands"
end
test "Ctrl+K opens, Escape closes" do
view =
mount!(CommandPalette, %{})
|> key_down!("k", ctrl: true)
assert render_text(view) =~ "Search commands"
view = key_down!(view, "Escape")
refute render_text(view) =~ "Search commands"
end
test "clicking a result fires on_select and closes" do
test_pid = self()
view =
mount!(CommandPalette, %{on_select: fn cmd -> send(test_pid, {:selected, cmd}) end})
|> key_down!("k", ctrl: true)
|> click!(".result:first-child")
assert_receive {:selected, _}
refute render_text(view) =~ "Search commands"
end
end