PhoenixHtmldriver

A lightweight Phoenix library for testing pure HTML interactions without the overhead of a headless browser. PhoenixHtmldriver provides a human-like API for testing Phoenix applications' HTML output, inspired by Capybara and Wallaby but optimized for pure HTML testing.

Features

Installation

Add phoenix_htmldriver to your list of dependencies in mix.exs:

def deps do
  [
    {:phoenix_htmldriver, "~> 0.10.0"}
  ]
end

Usage

Basic Example with use PhoenixHtmldriver (Recommended)

The easiest way to use PhoenixHtmldriver is with the use PhoenixHtmldriver macro, which automatically configures the endpoint:

defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  use PhoenixHtmldriver  # Automatically configures endpoint!
  alias PhoenixHtmldriver.{Form, Assertions}

  test "login flow", %{conn: conn} do
    # No manual setup needed - conn is automatically configured
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(username: "alice", password: "secret")
    |> Form.submit()
    |> Assertions.assert_text("Welcome, alice")
    |> Assertions.assert_selector(".alert-success")
  end
end

Important: Make sure to set @endpointbeforeuse PhoenixHtmldriver:

defmodule MyTest do
  use ExUnit.Case

  @endpoint MyAppWeb.Endpoint  # Must come before use PhoenixHtmldriver
  use PhoenixHtmldriver

  # Tests...
end

Manual Configuration (Advanced)

If you need more control, you can import functions directly:

defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  import PhoenixHtmldriver
  alias PhoenixHtmldriver.{Form, Assertions}

  setup %{conn: conn} do
    conn = Plug.Conn.put_private(conn, :phoenix_endpoint, MyAppWeb.Endpoint)
    %{conn: conn}
  end

  test "login flow", %{conn: conn} do
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(username: "alice", password: "secret")
    |> Form.submit()
    |> Assertions.assert_text("Welcome, alice")
  end
end

Navigation

alias PhoenixHtmldriver.{Session, Link}

# Visit a page
session = visit(conn, "/home")

# Click a link by selector
session =
  session
  |> Link.new("#about-link")
  |> Link.click()

# Click a link by text
session =
  session
  |> Link.new("About Us")
  |> Link.click()

# Get current path
path = Session.path(session)

Forms

alias PhoenixHtmldriver.Form

# Fill in a form and submit
session =
  session
  |> Form.new("#contact-form")
  |> Form.fill(
    name: "Alice",
    email: "alice@example.com",
    message: "Hello!"
  )
  |> Form.submit()

# Or chain it all together
session =
  session
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()

# Fill supports both keyword lists and maps
session =
  session
  |> Form.new("form")
  |> Form.fill(%{user: %{email: "test@example.com", password: "secret"}})
  |> Form.submit()

# Uncheck a checkbox
session =
  session
  |> Form.new("#preferences-form")
  |> Form.fill(newsletter: true)
  |> Form.uncheck(:newsletter)
  |> Form.submit()

# CSRF tokens are automatically extracted and included!
# No manual CSRF handling needed for forms with CSRF protection

Assertions

alias PhoenixHtmldriver.Assertions

# Assert text is present
session = Assertions.assert_text(session, "Welcome back")

# Assert element exists
session = Assertions.assert_selector(session, ".alert-success")
session = Assertions.assert_selector(session, "#user-profile")

# Assert element does not exist
session = Assertions.refute_selector(session, ".alert-danger")

Finding Elements

alias PhoenixHtmldriver.Element

# Find a single element (raises if not found)
element = Element.new(session, ".user-name")
text = Element.text(element)

# Get element attributes
element = Element.new(session, "#profile-link")
href = Element.attr(element, "href")
has_id = Element.has_attr?(element, "id")

Inspecting Responses

alias PhoenixHtmldriver.Session

# Get current HTML
html = Session.html(session)

# Get current path
path = Session.path(session)

API Reference

Core Module

Session Module

Form Module

Link Module

Element Module

Assertions Module

Session and Cookie Handling

PhoenixHtmldriver automatically preserves session cookies across requests, enabling you to test multi-step flows naturally:

alias PhoenixHtmldriver.{Form, Link, Assertions}

test "login flow with session", %{conn: conn} do
  visit(conn, "/login")
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()
  |> Assertions.assert_text("Welcome back!")
  |> Link.new("Profile")
  |> Link.click()
  |> Assertions.assert_text("user@example.com")
  # Session cookies are automatically preserved throughout!
end

How it works:

Automatic Redirect Following

PhoenixHtmldriver automatically follows HTTP redirects, just like a real browser:

alias PhoenixHtmldriver.{Session, Form, Assertions}

test "form submission follows redirect", %{conn: conn} do
  session =
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(email: "test@example.com", password: "secret")
    |> Form.submit()
    # Automatically follows 302 redirect to /dashboard

  assert Session.path(session) == "/dashboard"
  Assertions.assert_text(session, "Welcome back!")
end

Features:

CSRF Protection

PhoenixHtmldriver automatically handles CSRF tokens for you! When submitting forms, it:

  1. Looks for a hidden _csrf_token input field within the form
  2. Falls back to a <meta name="csrf-token"> tag in the document head
  3. Automatically includes the token in POST, PUT, PATCH, and DELETE requests
  4. Never overrides tokens you explicitly provide
  5. Works seamlessly with session cookies to ensure tokens validate correctly

This means you can test forms with CSRF protection without any extra setup:

alias PhoenixHtmldriver.{Form, Assertions}

test "login with CSRF protection", %{conn: conn} do
  visit(conn, "/login")
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()
  |> Assertions.assert_text("Welcome back!")
  # Both CSRF token AND session cookie were automatically handled!
end

How It Works

PhoenixHtmldriver uses Floki to parse HTML and Plug.Test to simulate HTTP requests. Unlike browser-based testing tools, it works directly with your Phoenix application's conn struct, making tests fast and reliable.

The library maintains a Session struct that tracks:

This allows for natural chaining of interactions while maintaining the state of the "browsing session".

Comparison with Other Tools

vs. Wallaby/Hound (Browser-based)

vs. Phoenix.ConnTest (Direct)

When to Use

PhoenixHtmldriver is perfect for:

It's not suitable for:

Examples

Testing a Multi-Step Flow

alias PhoenixHtmldriver.{Form, Link, Assertions}

test "user registration and profile update", %{conn: conn} do
  # Register a new user
  session =
    visit(conn, "/register")
    |> Form.new("#registration-form")
    |> Form.fill(
      username: "alice",
      email: "alice@example.com",
      password: "secret123"
    )
    |> Form.submit()

  # Verify registration success
  session = Assertions.assert_text(session, "Welcome, alice!")

  # Navigate to profile
  session =
    session
    |> Link.new("Edit Profile")
    |> Link.click()

  # Update profile
  session
  |> Form.new("#profile-form")
  |> Form.fill(bio: "Hello, I&#39;m Alice")
  |> Form.submit()
  |> Assertions.assert_text("Profile updated successfully")
end

Testing with Assertions

alias PhoenixHtmldriver.{Form, Assertions}

test "validates form submission", %{conn: conn} do
  visit(conn, "/contact")
  |> Form.new("#contact-form")
  |> Form.fill(name: "")  # Submit empty form
  |> Form.submit()
  |> Assertions.assert_text("Name is required")
  |> Assertions.assert_selector(".error-message")
  |> Assertions.refute_selector(".success-message")
end

Setting Up in Your Tests

Option 1: Using use PhoenixHtmldriver (Recommended)

The simplest way - just add use PhoenixHtmldriver after setting @endpoint:

defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase

  @endpoint MyAppWeb.Endpoint  # Must come first!
  use PhoenixHtmldriver
  alias PhoenixHtmldriver.Assertions

  # No setup needed - conn is automatically configured!

  test "home page", %{conn: conn} do
    visit(conn, "/")
    |> Assertions.assert_text("Welcome")
  end
end

Option 2: Manual Setup

If you need more control or prefer explicit configuration:

defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  import PhoenixHtmldriver
  alias PhoenixHtmldriver.Assertions

  setup %{conn: conn} do
    conn = Plug.Conn.put_private(conn, :phoenix_endpoint, MyAppWeb.Endpoint)
    %{conn: conn}
  end

  test "home page", %{conn: conn} do
    visit(conn, "/")
    |> Assertions.assert_text("Welcome")
  end
end

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.