Wallaby: Browser-Based Acceptance Testing for Elixir and Phoenix
Phoenix.LiveViewTest and Phoenix.ConnTest test your application logic without a browser. For acceptance tests that verify the full user experience — JavaScript behavior, CSS-driven interactions, and visual rendering — you need real browser automation. Wallaby provides this for Elixir, integrating tightly with ExUnit and supporting concurrent test execution.
What Wallaby Does
Wallaby drives real browsers (Chrome via ChromeDriver or PhantomJS) and provides an Elixir-native API for interacting with them. Its key design decisions:
- Concurrent by default — each test runs in its own browser session, enabling parallel execution with ExUnit's
async: true - Fluent, pipe-friendly API — interactions chain with
|>, making test code read naturally - Ecto integration — works with
Ecto.Adapters.SQL.Sandboxfor database isolation in concurrent tests
Installation
Add Wallaby to mix.exs:
defp deps do
[
{:wallaby, "~> 0.30", runtime: false, only: :test}
]
endYou also need ChromeDriver installed. On macOS:
brew install chromedriverOr via npm: npm install -g chromedriver
Configure in config/test.exs:
config :wallaby,
otp_app: :my_app,
driver: Wallaby.Chrome,
chrome: [headless: true]
# Start Phoenix endpoint for tests
config :my_app, MyAppWeb.Endpoint,
server: true,
http: [port: 4001]Start Wallaby in test/test_helper.exs:
ExUnit.start()
{:ok, _} = Application.ensure_all_started(:wallaby)
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)Setting Up a Feature Case
Create a shared case module for feature tests:
defmodule MyAppWeb.FeatureCase do
use ExUnit.CaseTemplate
using do
quote do
use Wallaby.Feature
import Wallaby.Query
import MyAppWeb.Router.Helpers
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
end
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(MyApp.Repo, self())
{:ok, session} = Wallaby.start_session(metadata: metadata)
%{session: session}
end
endWriting Feature Tests
Feature tests use Wallaby.Feature and receive a session:
defmodule MyAppWeb.UserRegistrationTest do
use MyAppWeb.FeatureCase, async: true
import Wallaby.Query
test "user can register and log in", %{session: session} do
session
|> visit("/register")
|> fill_in(text_field("Email"), with: "alice@example.com")
|> fill_in(text_field("Password"), with: "secret123")
|> fill_in(text_field("Confirm password"), with: "secret123")
|> click(button("Create account"))
|> assert_has(css(".alert-info", text: "Check your email to confirm"))
end
endThe pipe chain reads like a user story. Each step returns the session, enabling seamless chaining.
Querying Elements
Wallaby queries describe DOM elements to interact with:
# CSS selectors
css(".my-class")
css("#my-id")
css("table tbody tr", count: 5)
css(".error", text: "can't be blank")
# Form fields
text_field("Email") # by label text
text_field("email") # by name/id
fill_in(text_field("Search"), with: "elixir testing")
# Buttons and links
button("Submit")
link("Sign in")
# XPath
xpath("//div[@data-testid='notification']")Add count: to assert an exact number of elements:
assert_has(session, css("li.result", count: 10))Interacting with Elements
Common interaction functions:
# Navigation
visit(session, "/products")
visit(session, route_path(conn, :index))
# Clicking
click(session, button("Add to cart"))
click(session, link("View details"))
click(session, css("input[type='checkbox']"))
# Typing
fill_in(session, text_field("Name"), with: "Alice")
clear(session, text_field("Search"))
# Select dropdowns
select(session, option("Large"), from: select("Size"))
# File upload
attach_file(session, file_field("Avatar"), path: "test/fixtures/avatar.png")Asserting on the Page
# Element exists
assert_has(session, css(".success-banner"))
assert_has(session, css("h1", text: "Welcome, Alice"))
# Element does not exist
refute_has(session, css(".error"))
# Current URL
assert current_url(session) =~ "/dashboard"
assert current_path(session) == "/users/1"Handling JavaScript and Dynamic Content
Modern Phoenix apps use JavaScript for interactive behaviors. Wallaby executes real JavaScript and waits for the DOM to settle:
test "dropdown opens on click", %{session: session} do
session
|> visit("/products")
|> click(css(".filter-toggle"))
|> assert_has(css(".filter-panel", visible: true))
endFor explicit waiting when something takes time to appear:
# Wallaby retries queries by default (configurable timeout)
config :wallaby, js_errors: false, js_logger: false
# Or within a test
|> assert_has(css(".async-result", text: "Done", count: 1))Execute arbitrary JavaScript:
Wallaby.Browser.execute_script(session, "window.scrollTo(0, document.body.scrollHeight)")Taking Screenshots
Capture screenshots during tests for debugging:
Wallaby.Browser.take_screenshot(session)
# Saves to wallaby_screenshots/ directory
# Take screenshot at a specific step
session
|> visit("/checkout")
|> fill_in(text_field("Card number"), with: "4242 4242 4242 4242")
|> Wallaby.Browser.take_screenshot()
|> click(button("Pay now"))Concurrent Tests with Database Isolation
Wallaby was designed for concurrent execution. Each session includes sandbox metadata so the database sandbox can isolate concurrent test transactions:
defmodule MyAppWeb.ConcurrentFeatureTest do
use MyAppWeb.FeatureCase, async: true # Runs in parallel
test "shopping cart persists items", %{session: session} do
# Each test gets its own DB transaction and browser session
product = insert(:product, name: "Widget", price: 9.99)
session
|> visit("/products/#{product.id}")
|> click(button("Add to cart"))
|> visit("/cart")
|> assert_has(css(".cart-item", text: "Widget"))
end
endMulti-Session Tests
Test interactions between multiple users:
test "admin can see user activity", %{session: admin_session} do
{:ok, user_session} = Wallaby.start_session()
# User logs in and does something
user_session
|> visit("/login")
|> fill_in(text_field("Email"), with: "user@example.com")
|> fill_in(text_field("Password"), with: "password")
|> click(button("Sign in"))
|> visit("/dashboard")
# Admin sees the activity
admin_session
|> visit("/admin/activity")
|> assert_has(css(".activity-log", text: "user@example.com visited /dashboard"))
Wallaby.end_session(user_session)
endContinuous Monitoring with HelpMeTest
Wallaby runs acceptance tests during your CI pipeline. HelpMeTest extends this to 24/7 continuous monitoring — running your critical user flows against production every few minutes, alerting you when they fail. This catches issues that only appear in production (CDN caching, third-party service degradation, data migration side effects) before users report them.
For new teams, HelpMeTest's plain-English test format lets non-developers write acceptance criteria that run in real browsers — complementing the Elixir developer workflow Wallaby provides.
Summary
Wallaby brings acceptance testing to the Elixir ecosystem:
- Real browser — tests JavaScript, CSS, and full-stack rendering
- Concurrent — parallel sessions, async: true compatible
- Pipe-based API — readable, chainable test steps
- Ecto integration — database isolation for concurrent tests
- Screenshot support — captures state for debugging
For most Phoenix applications, the testing stack looks like: ExUnit for unit tests → ConnTest/LiveViewTest for integration → Wallaby for acceptance. Each layer catches different classes of bugs, and together they give you confidence to ship.