Wallaby: Browser-Based Acceptance Testing for Elixir and Phoenix

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.Sandbox for database isolation in concurrent tests

Installation

Add Wallaby to mix.exs:

defp deps do
  [
    {:wallaby, "~> 0.30", runtime: false, only: :test}
  ]
end

You also need ChromeDriver installed. On macOS:

brew install chromedriver

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

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

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

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

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

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

Read more