Cerberus
Cerberus is an experimental Phoenix testing library with one API across:
- non-browser Phoenix mode with static/live auto-detection and switching,
- browser mode for Chrome and Firefox via WebDriver BiDi.
Minimal API: You control your tests. Easily run single tests/describe blocks/entire modules in one or more browsers.
Performance Highlight
Live (non-browser) assertions are optimized for large pages:
assert_has/refute_hasin live mode read from LiveViewTest's internal patched DOM tree,- they avoid the previous
render(view)-> HTML string -> LazyHTML re-parse loop on each assertion, - matcher semantics stay in Cerberus so locators/matching remain consistent across drivers.
Browser assertions/path checks use an in-browser wait loop as the fast path:
- assertion/path polling happens inside browser JS (not Elixir-side polling),
- Cerberus adds a bounded transient retry wrapper when BiDi eval hits navigation/context-reset races,
- retries keep the original timeout budget semantics while reducing flaky transition failures.
30-Second Start
# mix.exs
{:cerberus, "~> 0.1"}
import Cerberus
session()
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")
Tip
Start with
session()for most scenarios. Move tosession(:browser)when validating real browser behavior, keyboard/mouse APIs, or browser-only assertions. Usesession(conn)when you need to continue from an existingPlug.Connstate. Usesession(:chrome)/session(:firefox)when you want an explicit browser target. Project CI currently runs Chrome lanes only; run Firefox lanes explicitly when needed.
Progressive Examples
1. Static Text Assertions
session()
|> visit("/articles")
|> assert_has(~l"Articles")
|> refute_has(~l"500 Internal Server Error")
2. LiveView Interaction
session()
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")
3. Form + Path Assertions
session()
|> visit("/search")
|> fill_in("Search term", "Aragorn")
|> submit(~l"button:Run Search"r)
|> assert_path("/search/results", query: %{q: "Aragorn"})
|> assert_has(~l"Search query: Aragorn")
4. Scope + Navigation
session()
|> visit("/scoped")
|> within(~l"#secondary-panel"c, fn scoped ->
scoped
|> assert_has(~l"Status: secondary")
|> click(~l"link:Open"r)
end)
|> assert_path("/search")
within/3 expects locator input (for example, within(~l"#secondary-panel"c, fn s -> ... end)).
In browser sessions, locator-based within/3 can switch root into same-origin iframes.
Scoped text assertions also support plain-string shorthand:
session()
|> visit("/scoped")
|> assert_has(~l"#secondary-panel"c, "Status: secondary")
|> refute_has(~l"#secondary-panel"c, "Status: primary")
Scoped assertion overloads use explicit scope and locator arguments:
assert_has(session, scope_locator, locator, opts \\ [])refute_has(session, scope_locator, locator, opts \\ [])
Field-wrapper assertion pattern (Phoenix core_components-style wrappers):
session()
|> visit("/field-wrapper-errors")
|> assert_has(closest(~l".fieldset"c, from: ~l"textbox:Email"r), ~l"can't be blank")
5. Multi-User + Multi-Tab
primary =
session()
|> visit("/session/user/alice")
tab2 =
primary
|> open_tab()
|> visit("/session/user")
user2 =
session()
|> visit("/session/user")
6. Browser-Only Extensions
import Cerberus.Browser
session =
session(:browser)
|> visit("/browser/extensions")
|> type("hello", selector: "#keyboard-input")
|> press("Enter", selector: "#press-input")
evaluate_js(session, "setTimeout(() => document.getElementById('confirm-dialog')?.click(), 10)")
session =
session
|> assert_dialog(~l"Delete item?"e, accept: false)
|> click(~l"link:Download Report"r)
|> assert_download("report.txt")
session
|> assert_has(~l"Press result: submitted")
|> assert_has(~l"Dialog result: cancelled")
main =
session(:browser)
|> visit("/browser/popup/click")
|> with_popup(
fn source ->
click(source, ~l"button:Open Popup"r)
end,
fn source, popup ->
assert_path(source, "/browser/popup/click")
assert_path(popup, "/browser/popup/destination", query: %{source: "click-trigger"})
end
)
Warning
Cerberus.Browser.*helpers are intentionally browser-only and raise explicit unsupported-operation assertions on non-browser sessions. Cross-origin iframe DOM access is still blocked by browser same-origin policy; use provider-level or parent-page assertions for those flows.
Locator Quick Look
How to choose locators:
- default to user-facing selectors first:
- form labels for input actions (
fill_in("Email", "..."),check("Receive updates")) - role + accessible name for controls (
~l"button:Save"r,~l"link:Billing"r) - visible text for assertions (
assert_has(~l"Settings saved"e))
- form labels for input actions (
- when repeated text makes matching ambiguous, scope first with
within/3 - use
testid("...")when text/role is not stable enough - use CSS locators for structure-focused targeting only
Canonical helper constructors:
text("..."),link("..."),button("..."),label("..."),testid("..."),css("..."),role(:button, name: "...")
Composition (advanced):
- same-element AND:
left |> right(...) - OR alternatives:
or_(left, right) - descendant requirement:
has(locator, nested) - nearest ancestor scope:
closest(base, from: nested) - examples:
click(button("Apply") |> testid("apply-secondary-button"))click(button("Apply") |> has(testid("apply-secondary-marker")))
Sigil ~l:
~l"Save"text~l"Save"eexact text~l"Save"iinexact text~l"button:Save"rrole form (ROLE:NAME)~l"button[type='submit']"ccss form~l"save-button"ttestid form (defaults to exact matching)- rules:
- at most one kind modifier (
r,c, ort) eandiare mutually exclusiverrequiresROLE:NAME
- at most one kind modifier (
Role helpers currently support practical aliases used by actions/assertions:
- click/assert roles:
button,menuitem,tab,link,heading,img - form-control roles:
textbox,searchbox,combobox,listbox,spinbutton,checkbox,radio,switch
For a longer walkthrough with step-by-step locator examples, see docs/getting-started.md.
Switching Modes
Most tests switch modes by changing only the first session line:
-session()
+session(:browser)
|> visit("/live/counter")
|> click(~l"button:Increment"r)
|> assert_has(~l"Count: 1")
Per-Test Browser Overrides
You can override browser defaults in one test by passing session opts:
session(:browser,
ready_timeout_ms: 2_500,
user_agent: "Cerberus Mobile Spec",
browser: [viewport: {390, 844}]
)
|> visit("/live/counter")
Isolation strategy:
- runtime process + BiDi transport stay shared,
- each
session(:browser, ...)creates an isolated browser user context, - context-level overrides (viewport/user-agent/popup mode/init scripts) are isolated per session and do not require a dedicated browser process.
SQL sandbox helper:
metadata = Cerberus.sql_sandbox_user_agent(MyApp.Repo, context)
session(:browser, user_agent: metadata)
Popup behavior:
- Preferred: use
Browser.with_popup/4for deterministic popup capture and two-session assertions. popup_mode: :allowkeeps browser default popup/new-window behavior (default).popup_mode: :same_tabinjects an early preload script that rewriteswindow.open(...)to same-tab navigation.:same_tabis a pragmatic fallback for autonomous flows that you cannot reliably trigger from test callbacks.
Same-tab workaround (OAuth-style redirect/result flow):
session(:browser, browser: [popup_mode: :same_tab])
|> visit("/browser/popup/auto")
|> assert_path("/browser/popup/destination", query: %{source: "auto-load"}, timeout: 1_500)
|> assert_has(~l"popup source: auto-load"e)
When workaround is brittle:
- popup behavior depends on browser security/user gesture requirements,
- popup source script changes frequently or is third-party hosted,
- flow needs asserting both opener and popup side effects.
In those cases, prefer Browser.with_popup/4 and assert both main and popup sessions directly.
Browser Defaults and Runtime Options
You can configure defaults once:
config :cerberus, :assert_timeout_ms, 300
config :cerberus, :browser,
ready_timeout_ms: 2_200,
ready_quiet_ms: 40,
bidi_command_timeout_ms: 5_000,
runtime_http_timeout_ms: 9_000,
dialog_timeout_ms: 1_500,
screenshot_full_page: false,
screenshot_artifact_dir: "tmp/cerberus-artifacts/screenshots",
show_browser: false
Override precedence is:
- call opts (
assert_has(..., timeout: ...)) - session opts (
session(assert_timeout_ms: ...),session(:browser, ready_timeout_ms: ...),session(:browser, ready_quiet_ms: ...), context overrides insession(:browser, browser: [...])) - app config
- hardcoded fallback
Assertion-timeout fallback:
- Live and Browser assertions (
assert_*/refute_*, includingassert_path/refute_path) default to500ms. - Static assertions remain immediate unless you pass explicit call/session/app timeout overrides.
Option scopes:
- Per-session context options:
ready_timeout_ms,ready_quiet_ms,user_agent,browser: [viewport: ..., user_agent: ..., popup_mode: :allow | :same_tab, init_script: ... | init_scripts: [...]]. - Global runtime launch options:
browser_name,webdriver_url,chrome_webdriver_url,firefox_webdriver_url,show_browser,headless,chrome_args,firefox_args,chrome_binary,firefox_binary,chromedriver_binary,geckodriver_binary. - Global browser defaults:
bidi_command_timeout_ms,runtime_http_timeout_ms,dialog_timeout_ms,screenshot_full_page,screenshot_artifact_dir,screenshot_path.
show_browser: true runs headed by default. headless has higher precedence if both are set.
Because browser runtime + BiDi transport are shared per browser lane, runtime launch options should be treated as invocation-level config (not per-test toggles).
Learn More
Browser Runtime Setup
Cerberus browser tests use WebDriver BiDi. Chrome and Firefox are supported browser targets. Project CI currently runs Chrome lanes only; Firefox lanes remain available for explicit local/manual runs.
Local managed runtime (default) uses configured browser and WebDriver binaries:
config :cerberus, :browser,
chrome_binary: "/path/to/chrome-or-chromium",
chromedriver_binary: "/path/to/chromedriver",
firefox_binary: "/path/to/firefox",
geckodriver_binary: "/path/to/geckodriver"
Only the selected browser lane needs to be configured for a given run.
Headed mode:
config :cerberus, :browser, show_browser: true
Remote runtime mode:
config :cerberus, :browser,
webdriver_url: "http://127.0.0.1:4444"
With webdriver_url set, Cerberus does not launch local browser/WebDriver processes.
For explicit multi-browser remote lanes in one invocation:
config :cerberus, :browser,
chrome_webdriver_url: "http://127.0.0.1:4444",
firefox_webdriver_url: "http://127.0.0.1:5555"
Remote webdriver_url integration smoke test (Docker required):
CERBERUS_REMOTE_WEBDRIVER=1 mix test test/cerberus/remote_webdriver_behavior_test.exs
This test starts a selenium/standalone-chromium container with docker run,
connects Cerberus through webdriver_url, and force-removes the container on exit.
Global remote-browser invocation (Docker required):
mix test.websocket
mix test.websocket test/cerberus/explicit_browser_test.exs
mix test.websocket starts/stops Selenium container(s) and runs one mix test
invocation with remote browser lane wiring. Use --browsers when needed to
override lane provisioning (chrome, firefox, or all); prefer Chrome for
regular local runs.
Mixed-driver local browser run:
mix test test/cerberus
Cerberus uses mixed-driver suites (no dedicated :browser tag lane), so browser coverage runs as part of normal test/cerberus execution.
Explicit browser-lane override coverage:
mix test test/cerberus/explicit_browser_test.exs
Install local browser runtimes with public Mix tasks:
mix cerberus.install.chrome --version 146.0.7680.31
mix cerberus.install.firefox --firefox-version 148.0 --geckodriver-version 0.36.0
Both tasks install missing binaries and reuse existing per-version installations.
Version precedence is flags first, then matching env vars (CERBERUS_CHROME_VERSION, CERBERUS_FIREFOX_VERSION, CERBERUS_GECKODRIVER_VERSION), then defaults (latest stable Chrome/Firefox and GeckoDriver 0.36.0).
Stable output contracts:
--format jsonfor machine-readable payloads (paths, versions, env handoff keys)--format envforKEY=VALUElines (for CI env files)--format shellforexport KEY='VALUE'lines
After install, Cerberus automatically discovers local managed-runtime binaries via stable links:
tmp/chrome-currenttmp/chromedriver-currenttmp/firefox-currenttmp/geckodriver-current
No extra binary-path config is required for normal local runs after installation.
CI-friendly form:
mix cerberus.install.chrome --format env >> "$GITHUB_ENV"
mix cerberus.install.firefox --format env >> "$GITHUB_ENV"
Installed paths are stable per version, for example:
tmp/chrome-<version>tmp/chromedriver-<version>tmp/firefox-<version>tmp/geckodriver-<version>
Migration Task
Cerberus includes an Igniter migration task for PhoenixTest codebases:
mix cerberus.migrate_phoenix_test
mix cerberus.migrate_phoenix_test --write test/my_app_web/features
It performs safe rewrites, reports manual follow-ups, and defaults to dry-run diff output.
Migration verification docs are maintainer-focused and kept in the repository under docs/migration-verification*.md.