Wallaby: End-to-End Browser Testing for Phoenix Applications
Wallaby is an Elixir browser testing library that drives real browsers through WebDriver. Where Phoenix.LiveViewTest tests the server side without a browser, Wallaby tests the complete stack — JavaScript execution, CSS animations, WebSocket connections, and real browser rendering. It's the closest you get to testing what users actually experience.
Wallaby integrates with Ecto's SQL sandbox for concurrent database tests — multiple browser sessions can run in parallel safely.
Setup
# mix.exs
defp deps do
[
{:wallaby, "~> 0.30", only: :test, runtime: false}
]
end# config/test.exs
config :wallaby,
otp_app: :my_app,
driver: Wallaby.Chrome,
chrome: [
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"]
]
# Phoenix endpoint base URL for tests
config :my_app, MyAppWeb.Endpoint,
url: [host: "localhost", port: 4002]# test/test_helper.exs
{:ok, _} = Application.ensure_all_started(:wallaby)
Application.put_env(:wallaby, :base_url, MyAppWeb.Endpoint.url())
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)# test/support/feature_case.ex
defmodule MyApp.FeatureCase do
use ExUnit.CaseTemplate
using do
quote do
use Wallaby.Feature
import Wallaby.Query, only: [css: 1, css: 2, button: 1, link: 1, text_field: 1, fillable_field: 1]
alias MyApp.Repo
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, session: Wallaby.start_session()}
end
endWriting Your First Feature Test
defmodule MyApp.UserRegistrationTest do
use MyApp.FeatureCase, async: true
@tag :browser
feature "user can register and sign in", %{session: session} do
session
|> visit("/users/register")
|> fill_in(text_field("Email"), with: "alice@example.com")
|> fill_in(text_field("Password"), with: "SecurePass123!")
|> fill_in(text_field("Confirm password"), with: "SecurePass123!")
|> click(button("Create account"))
|> assert_text("Welcome to MyApp")
|> assert_has(css(".flash-notice", text: "Account created successfully"))
end
endWallaby's API is pipe-based — each function returns the session, making chains readable.
Query DSL
Wallaby finds elements through queries. Import from Wallaby.Query:
import Wallaby.Query
# CSS selector
css(".my-class")
css("#my-id")
css("button[type='submit']")
css(".product-card", count: 3) # assert exactly 3 match
css(".item", minimum: 1) # assert at least 1 matches
css(".item", at: 1) # find second matching element (0-indexed)
# Form elements
button("Submit Order")
link("Sign in")
text_field("Email")
fillable_field("Search") # any input that can be filled
select("Role")
checkbox("Accept terms")
radio_button("Option A")
file_field("Profile photo")
# XPath (when CSS isn't enough)
xpath("//table/tr[1]/td[@class='price']")Navigation
session
|> visit("/dashboard")
|> visit("https://example.com/external") # absolute URL
# Current URL assertion
assert_has(session, css("h1", text: "Dashboard"))
# Back/forward
session
|> visit("/page-a")
|> click(link("Go to Page B"))
|> execute_script("window.history.back()")Form Interactions
feature "creates a post", %{session: session} do
session
|> visit("/posts/new")
|> fill_in(fillable_field("Title"), with: "My First Post")
|> fill_in(css("textarea[name='post[body]']"), with: "Post content goes here.")
|> select("Category", option: "Technology")
|> check(checkbox("Publish immediately"))
|> click(button("Save Post"))
|> assert_has(css("h1", text: "My First Post"))
|> assert_has(css(".flash-notice", text: "Post created"))
endJavaScript Interactions
Wallaby automatically waits for elements before interacting. For JavaScript-heavy UIs:
feature "autocomplete suggests results", %{session: session} do
session
|> visit("/search")
|> fill_in(fillable_field("Search"), with: "pho")
|> assert_has(css(".autocomplete-dropdown"))
|> assert_has(css(".autocomplete-option", text: "Phoenix"))
|> click(css(".autocomplete-option", text: "Phoenix"))
|> assert_has(css("#search-input[value='Phoenix']"))
endExecuting JavaScript Directly
# Run arbitrary JavaScript
execute_script(session, "window.scrollTo(0, document.body.scrollHeight)")
# Get a return value
{session, result} = execute_script(session, "return document.title")
assert result == "My App - Dashboard"
# Click hidden elements or trigger JS events
execute_script(session, "document.querySelector('.hidden-button').click()")Handling Modals and Alerts
feature "confirms before deleting", %{session: session} do
post = Factory.insert(:post)
session
|> visit("/posts/#{post.id}")
|> click(button("Delete Post"))
|> accept_alert() # browser confirm() dialog
|> assert_has(css(".flash-notice", text: "Post deleted"))
|> assert_has(css("h1", text: "Posts"))
end
# Dismiss
session |> dismiss_alert()
# Confirm with specific text check
session |> accept_alert(fn alert_text ->
assert alert_text == "Are you sure you want to delete?"
end)Authentication
Avoid logging in through the UI in every test — it's slow. Use programmatic sign-in:
# test/support/feature_case.ex
def sign_in(session, user) do
session
|> visit("/users/log_in")
|> fill_in(text_field("Email"), with: user.email)
|> fill_in(text_field("Password"), with: "test_password")
|> click(button("Sign in"))
endOr bypass the UI entirely by setting session cookies directly:
def authenticate(session, user) do
token = MyApp.Tokens.generate(user)
session
|> visit("/") # visit any page to establish session
|> execute_script("""
document.cookie = 'user_token=#{token}; path=/';
""")
|> visit("/dashboard") # reload with auth cookie set
endConcurrent Sessions
Wallaby's killer feature is concurrent browser sessions. Multiple users can interact simultaneously in one test:
feature "two users see each other's messages in real time", %{session: session} do
alice = Factory.insert(:user, name: "Alice")
bob = Factory.insert(:user, name: "Bob")
alice_session = sign_in(session, alice)
bob_session = Wallaby.start_session() |> sign_in(bob)
alice_session
|> visit("/rooms/general")
bob_session
|> visit("/rooms/general")
|> fill_in(fillable_field("Message"), with: "Hello from Bob!")
|> click(button("Send"))
# Alice's session sees Bob's message
alice_session
|> assert_has(css(".message", text: "Hello from Bob!"))
Wallaby.end_session(bob_session)
endThis tests real WebSocket behavior and PubSub — something LiveView tests can't cover.
File Uploads
feature "uploads profile photo", %{session: session} do
user = Factory.insert(:user)
session
|> sign_in(user)
|> visit("/profile/edit")
|> attach_file(file_field("Profile photo"),
path: Path.expand("../fixtures/avatar.jpg", __DIR__))
|> click(button("Save Profile"))
|> assert_has(css("img.avatar"))
endTaking Screenshots
feature "checkout flow", %{session: session} do
session
|> visit("/checkout")
|> take_screenshot(name: "checkout-step-1")
|> fill_in(text_field("Card number"), with: "4242 4242 4242 4242")
|> take_screenshot(name: "checkout-step-2")
|> click(button("Pay Now"))
|> assert_has(css(".confirmation-number"))
endScreenshots save to screenshots/ by default. Wallaby automatically takes a screenshot when a test fails.
CI Configuration
GitHub Actions
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Run browser tests
env:
MIX_ENV: test
WALLABY_CHROME_BINARY: /usr/bin/google-chrome
run: mix test --only browserFiltering Browser Tests in CI
Keep fast tests and slow browser tests in separate CI jobs:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- run: mix test --exclude browser
browser-tests:
runs-on: ubuntu-latest
steps:
- run: mix test --only browserWallaby vs LiveViewTest
| Feature | LiveViewTest | Wallaby |
|---|---|---|
| Speed | Very fast | Slower (real browser) |
| JavaScript | Not executed | Full execution |
| WebSockets | Simulated | Real connections |
| CSS/animations | Not tested | Full rendering |
| File downloads | Limited | Full browser support |
| Setup complexity | Minimal | Chrome/WebDriver required |
| Best for | LiveView logic | End-to-end user flows |
Use both: LiveViewTest for comprehensive LiveView unit testing, Wallaby for critical end-to-end flows that must verify real browser behavior.
Continuous Production Monitoring
Wallaby tests run pre-deploy. HelpMeTest provides the same end-to-end scenario coverage running 24/7 against your production Phoenix application — no ChromeDriver to manage, no CI infra to maintain.
Summary
Wallaby brings real browser testing to Phoenix applications with an Elixir-idiomatic API. Its pipe-based session DSL reads like natural language. Concurrent sessions test multi-user flows and WebSocket behavior. Ecto sandbox integration makes concurrent database tests safe. Screenshot on failure simplifies debugging.
Use Wallaby for your critical user flows — registration, authentication, checkout, key features. Keep the suite lean (Wallaby tests are 10-50x slower than unit tests) and rely on LiveViewTest for thorough LiveView coverage.