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 explicit session(:phoenix)) gives a PhoenixTest-style flow: static and live routes are handled automatically behind one API. session(conn) reuses an existing Plug.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/css only 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"e exact text
  • ~l"Save"i inexact text
  • ~l"Email"l field label form (<label>, aria-labelledby, or aria-label)
  • ~l"button:Save"r role form (ROLE:NAME)
  • ~l"button[type='submit']"c css form
  • ~l"save-button"t testid form (exact: true default)
  • at most one kind modifier (r, c, l, or t)
  • e and i are mutually exclusive
  • r requires ROLE: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: n
  • min: n
  • max: n
  • between: {min, max} or between: min..max

Element-targeting actions also support position filters:

  • first: true
  • last: true
  • nth: 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 call timeout: .... The built-in defaults are 0ms for static and 500ms for 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: 100

Step 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: false

headless: false runs headed mode.

slow_mo adds a fixed delay (in milliseconds) before each browser BiDi command:

config :cerberus, :browser,
  slow_mo: 120

Runtime 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.