Wallaby: End-to-End Browser Testing for Phoenix Applications

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
end

Writing 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
end

Wallaby'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']")
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"))
end

JavaScript 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']"))
end

Executing 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"))
end

Or 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
end

Concurrent 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)
end

This 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"))
end

Taking 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"))
end

Screenshots 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 browser

Filtering 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 browser

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

Read more