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
- Session-based API: Chain interactions naturally with a session object
- Lightweight: No headless browser overhead - pure HTML parsing with Floki
- Phoenix Integration: Seamlessly works with Phoenix.ConnTest
- Human-readable: Intuitive API that mirrors user interactions
- Fast: Significantly faster than browser-based testing
Installation
Add phoenix_htmldriver to your list of dependencies in mix.exs:
def deps do
[
{:phoenix_htmldriver, "~> 0.10.0"}
]
endUsage
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
endImportant: Make sure to set @endpointbeforeuse PhoenixHtmldriver:
defmodule MyTest do
use ExUnit.Case
@endpoint MyAppWeb.Endpoint # Must come before use PhoenixHtmldriver
use PhoenixHtmldriver
# Tests...
endManual 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
endNavigation
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 protectionAssertions
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
PhoenixHtmldriver.visit/2- Navigate to a path (creates new session from conn, or navigates within existing session)
Session Module
Session.new(conn, path)- Create a new session from a Plug.ConnSession.get(session, path)- Navigate to a path within an existing sessionSession.path(session)- Get current request pathSession.html(session)- Get current response HTML
Form Module
Form.new(session, selector)- Create a form object from a CSS selectorForm.fill(form, values)- Fill in form fields (accepts keyword list or map)Form.uncheck(form, field)- Uncheck a checkbox fieldForm.submit(form)- Submit the form and return a new session
Link Module
Link.new(session, selector_or_text)- Find a link by CSS selector or text contentLink.click(link)- Click the link and return a new session
Element Module
Element.new(session, selector)- Find an element by CSS selector (raises if not found)Element.text(element)- Get element text contentElement.attr(element, name)- Get attribute valueElement.has_attr?(element, name)- Check if attribute exists
Assertions Module
Assertions.assert_text(session, text)- Assert text is presentAssertions.assert_selector(session, selector)- Assert element existsAssertions.refute_selector(session, selector)- Assert element doesn't exist
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!
endHow it works:
- Session cookies from responses are automatically extracted
-
Subsequent requests (
visit,click_link,submit_form) include these cookies - This enables proper session-based authentication and CSRF validation
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!")
endFeatures:
- Automatically follows 301, 302, 303, 307, and 308 redirects
- Handles redirect chains (up to 5 redirects deep)
- Preserves cookies across redirects
-
Works with
visit/2,Link.click/1, andForm.submit/1 Session.path/1returns the final destination after all redirects
CSRF Protection
PhoenixHtmldriver automatically handles CSRF tokens for you! When submitting forms, it:
-
Looks for a hidden
_csrf_tokeninput field within the form -
Falls back to a
<meta name="csrf-token">tag in the document head - Automatically includes the token in POST, PUT, PATCH, and DELETE requests
- Never overrides tokens you explicitly provide
- 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!
endHow 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:
- The current conn
- The parsed HTML document
- The latest response
- The endpoint being tested
This allows for natural chaining of interactions while maintaining the state of the "browsing session".
Comparison with Other Tools
vs. Wallaby/Hound (Browser-based)
- Faster: No browser startup overhead
- Simpler: No JavaScript support, pure HTML only
- More Reliable: No flaky browser interactions
- Limited: Cannot test JavaScript behavior
vs. Phoenix.ConnTest (Direct)
- More Natural: Human-like API vs. low-level HTTP
- Chainable: Session-based interactions
- HTML-aware: Built-in selectors and assertions
- Simpler Forms: Easy form filling and submission
When to Use
PhoenixHtmldriver is perfect for:
- Testing server-rendered HTML applications
- Controller and view testing
- Form submission flows
- Multi-step interactions
- Fast integration tests
It's not suitable for:
- Testing JavaScript-heavy applications
- Testing client-side interactions
- Testing WebSocket behavior
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'm Alice")
|> Form.submit()
|> Assertions.assert_text("Profile updated successfully")
endTesting 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")
endSetting 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
endOption 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
endLicense
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.