This guide moves from the smallest working Cerberus flow to advanced multi-session scenarios.
Core Mental Model
Cerberus is session-first. Every operation returns an updated session.
session()
|> visit("/articles")
|> assert_has(~l"Articles"e)Info
session()(or explicitsession(:phoenix)) gives a PhoenixTest-style flow: static and live routes are handled automatically behind one API.session(conn)reuses an existingPlug.Conn(including carried session/cookie state) instead of starting from a fresh conn.session(:browser)is the public browser entrypoint and runs Chrome.
Set the endpoint once globally (same style as PhoenixTest), then use plain session() in tests:
# test/test_helper.exs
Application.put_env(:cerberus, :endpoint, MyAppWeb.Endpoint)Step 1: First Useful Flow
session()
|> visit("/articles")
|> assert_has(~l"Articles"e)
|> refute_has(~l"500 Internal Server Error"e)Step 2: LiveView Interaction (Same API)
session()
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1"e)Step 3: Forms + Path Assertions
session()
|> visit("/search")
|> fill_in(~l"Search term"l, "Aragorn")
|> submit(~l"button:Run Search"r)
|> assert_path("/search/results", query: %{q: "Aragorn"})
|> assert_has(~l"Search query: Aragorn"e)Step 4: Scoped Interaction
session()
|> visit("/scoped")
|> within(~l"#secondary-panel"c, fn scoped ->
scoped
|> assert_has(~l"Status: secondary"e)
|> click(~l"link:Open"r)
end)
|> assert_path("/search")within/3 expects locator input (~l"#panel"c, ~l"button:Open"r, ~l"search-input"t, etc.). Browser locator scopes can switch into same-origin iframes.
Scoped text assertions use explicit locator arguments:
session()
|> visit("/scoped")
|> assert_has(~l"#secondary-panel"c, ~l"Status: secondary"e)
|> refute_has(~l"#secondary-panel"c, ~l"Status: primary"e)Scoped assertion overloads use explicit scope and locator arguments:
assert_has(session, scope_locator, locator, opts \\ [])refute_has(session, scope_locator, locator, opts \\ [])
State assertions are available as direct helpers:
session()
|> visit("/phoenix_test/page/index")
|> assert_checked(~l"Mail Choice"l)
|> refute_checked(~l"Email Choice"l)
|> assert_disabled(~l"Disabled textaread"l)
|> assert_readonly(~l"Readonly notes"l)Locator Basics (Phoenix/LiveView First)
A locator is how Cerberus finds elements for actions and assertions.
Start with the most user-facing option that is stable in your UI:
- form label text for form actions (
fill_in/3,check/2,choose/2,select/3) - role + accessible name for interactive controls (
button,link, etc.) - visible text for content assertions
testid/cssonly when user-facing text is ambiguous or intentionally hidden
Examples:
session()
|> visit("/settings")
|> fill_in(~l"Email"l, "alice@example.com")
|> check(~l"Receive updates"l)
|> click(~l"button:Save"r)
|> assert_has(~l"Settings saved"e)When a page has repeated labels/buttons, scope first:
session()
|> visit("/checkout")
|> within(~l"#shipping-address"c, fn scoped ->
scoped
|> fill_in(~l"City"l, "Berlin")
|> click(~l"button:Save"r)
end)Use testid when text/role cannot disambiguate reliably:
session()
|> visit("/live/selector-edge")
|> click(testid("apply-secondary-button"))
|> assert_has(~l"Selected: secondary"e)Locator sigil quick look:
~l"Save"exact text (default)~l"Save"eexact text~l"Save"iinexact text~l"Email"lfield label form (<label>,aria-labelledby, oraria-label)~l"button:Save"rrole form (ROLE:NAME)~l"button[type='submit']"ccss form~l"save-button"ttestid form (exact: truedefault)- at most one kind modifier (
r,c,l, ort) eandiare mutually exclusiverrequiresROLE:NAME- regex values are supported for text-like locators and role names, but cannot be combined with
exact: true|false
Match Count And Position Filters
Locator operations support shared count filters:
count: nmin: nmax: nbetween: {min, max}orbetween: min..max
Element-targeting actions also support position filters:
first: truelast: truenth: n(1-based)index: n(0-based)
Browser actions additionally support:
force: true(bypass browser actionability checks for the targeted action)
Default actionability behavior:
- browser actions wait for matched controls to become enabled before acting
- live actions retry briefly when a matched form control is still disabled after a preceding LiveView update
- static actions do not wait; disabled controls fail immediately
Example:
session() # or session(conn)
|> visit("/live/selector-edge")
|> fill_in(~l"Name"l, "primary", first: true, count: 2)
|> fill_in(~l"Name"l, "secondary", last: true, count: 2)Advanced Locator Composition (Optional)
You can compose locators when simple label/role/testid matching is not enough.
Common advanced patterns:
- scope chaining (descendant query):
css("#search-form") |> role(:button, name: "Run Search") - same-element intersection:
and_(role(:button, name: "Run Search"), testid("submit-secondary-button")) - descendant requirement:
role(:button, name: "Run Search") |> filter(has: testid("submit-secondary-marker")) - descendant exclusion:
role(:button, name: "Run Search") |> filter(has_not: testid("submit-secondary-marker")) - visibility constraint:
role(:button, name: "Run Search") |> filter(visible: true) - OR alternatives:
or_(css("#primary"), css("#secondary")) - boolean algebra:
and_(role(:button, name: "Run Search"), not_(testid("submit-secondary-button"))) - negated conjunction:
not_(and_(role(:button, name: "Run Search"), testid("submit-secondary-button")))
session()
|> visit("/live/selector-edge")
|> click(and_(role(:button, name: "Apply"), testid("apply-secondary-button")))
|> assert_has(~l"Selected: secondary"e)closest/2 is useful for Phoenix wrapper patterns where you want the nearest ancestor around another locator (for example, a fieldset around a label/control):
session()
|> visit("/field-wrapper-errors")
|> assert_has(closest(~l".fieldset"c, from: ~l"textbox:Email"r), ~l"can't be blank"e)Step 5: Multi-User + Multi-Tab
primary =
session()
|> visit("/session/user/alice")
|> assert_has(~l"Session user: alice"e)
_tab2 =
primary
|> open_tab()
|> visit("/session/user")
|> assert_has(~l"Session user: alice"e)
session()
|> visit("/session/user")
|> assert_has(~l"Session user: unset"e)
|> refute_has(~l"Session user: alice"e)Step 6: Async LiveView Assertions
session()
|> visit("/live/async_page")
|> assert_has(~l"Title loaded async"e)Tip
Timeouts are unified across assertions, actions, and path assertions. Default timeout precedence is: global all-driver config, then global per-driver config, then session
timeout_ms, then calltimeout: .... The built-in defaults are0msfor static and500msfor live/browser. Static assertions are one-shot. Live assertions and actions wait on LiveView progress before retrying. Browser assertions and actions wait natively in the browser driver rather than through a shared outer timeout loop.
Step 7: Browser-Only Extensions
import Cerberus.Browser
session =
session(:browser)
|> visit("/browser/extensions")
|> type(css("#keyboard-input"), "hello")
|> press(css("#press-input"), "Enter")
evaluate_js(session, "setTimeout(() => document.getElementById('confirm-dialog')?.click(), 10)", fn _ -> :ok end)
evaluate_js(session, "window.__cerberusMarker = 'ready'")
session =
session
|> assert_dialog(~l"Delete item?"e)
session
|> assert_has(~l"Press result: submitted"e)
|> assert_has(~l"Dialog result: cancelled"e)
png =
screenshot(session, path: "tmp/extensions.png", return_result: true)
cookie(session, "_cerberus_fixture_key", fn entry ->
assert entry
end)
session
|> add_session_cookie(
[value: %{session_user: "alice"}],
Cerberus.Fixtures.Endpoint.session_options()
)
|> visit("/session/user")
|> assert_has(~l"Session user: alice"e)Step 8: Per-Test Browser Overrides
session(:browser,
ready_timeout_ms: 2_500,
user_agent: "Cerberus Mobile Spec",
browser: [viewport: {390, 844}]
)
|> visit("/live/counter")
|> assert_has(~l"Count: 1"e)Use this when one test needs different browser characteristics (for example mobile viewport) without changing global config.
SQL sandbox user-agent helper:
metadata = Cerberus.Browser.user_agent_for_sandbox(MyApp.Repo, context)
session(:browser, user_agent: metadata)
# Optional: delay sandbox-owner shutdown for LiveView-heavy browser tests.
config :cerberus, ecto_sandbox_stop_owner_delay: 100Step 9: Install Local Browser Runtimes
Install browser binaries with Cerberus Mix tasks:
MIX_ENV=test mix cerberus.install.chrome
For explicit versions:
MIX_ENV=test mix cerberus.install.chrome --version 146.0.7680.31
Cerberus writes stable local links on install (tmp/chrome-current, tmp/chromedriver-current), so local managed browser runs work without extra binary-path config.
Step 10: Remote WebDriver Mode
config :cerberus, :browser,
webdriver_url: "http://127.0.0.1:4444"Remote mode connects to an already-running WebDriver endpoint and skips local browser/WebDriver launch.
To keep one global remote Chrome lane while still making the browser endpoint explicit:
config :cerberus, :browser,
chrome_webdriver_url: "http://127.0.0.1:4444"Step 11: Headed Browser and Runtime Launch Options
config :cerberus, :browser,
headless: falseheadless: false runs headed mode.
slow_mo adds a fixed delay (in milliseconds) before each browser BiDi command:
config :cerberus, :browser,
slow_mo: 120Runtime launch settings (for example headless, slow_mo, browser binaries, driver binaries, webdriver_url, and chrome_webdriver_url) are runtime-level and should be configured globally per test invocation, not per test.