Excessibility

View Source

Hex.pm Hex Docs CI License: MIT

Accessibility Snapshot Testing for Elixir + Phoenix

Excessibility helps you test your Phoenix apps for accessibility (WCAG compliance) by taking HTML snapshots during tests and running them through Pa11y.

Why Excessibility?

  • Keep accessibility in your existing test feedback loop. Snapshots are captured inside ExUnit, Wallaby, and LiveView tests, so regressions surface together with your functional failures.
  • Ship safer refactors. Explicit baseline locking and comparison lets reviewers see exactly what changed and approve intentionally.
  • Debug CI-only failures quickly. Pa11y output points to the failing snapshot, and the saved artifacts make it easy to reproduce locally.

How It Works

  1. During tests, call html_snapshot(conn) to capture HTML from your Phoenix responses, LiveViews, or Wallaby sessions
  2. After tests, run mix excessibility to check all snapshots with Pa11y for WCAG violations
  3. Lock baselines with mix excessibility.baseline when snapshots represent a known-good state
  4. Compare changes with mix excessibility.compare to review what changed and approve/reject
  5. In CI, Pa11y reports accessibility violations alongside your test failures

Features

Installation

Add to mix.exs:

def deps do
  [
    {:excessibility, "~> 0.5", only: [:dev, :test]}
  ]
end

Fetch dependencies and run the installer:

mix deps.get
mix excessibility.install

Apps with authentication: If your app requires login to access most pages, specify a public route for extracting <head> content:

mix excessibility.install --head-render-path /login

The installer will:

  • Add configuration to config/test.exs
  • Create a pa11y.json with sensible defaults for Phoenix/LiveView
  • Install Pa11y via npm in your assets directory

Quick Start

  1. Configure the endpoint and helper modules in config/test.exs. The installer does this automatically, or add manually:

     config :excessibility,
       endpoint: MyAppWeb.Endpoint,
       head_render_path: "/",  # use "/login" for apps with auth
       system_mod: Excessibility.System,
       browser_mod: Wallaby.Browser,
       live_view_mod: Excessibility.LiveView
  2. Add use Excessibility in tests where you want snapshots:

     defmodule MyAppWeb.PageControllerTest do
       use MyAppWeb.ConnCase, async: true
       use Excessibility
    
       test "renders home page", %{conn: conn} do
         conn = get(conn, "/")
         html_snapshot(conn, screenshot?: true)
         assert html_response(conn, 200) =~ "Welcome!"
       end
     end
  3. Typical workflow:

     # Write tests with html_snapshot calls, then:
     mix test                    # Generates snapshots in test/excessibility/
     mix excessibility           # Runs Pa11y against snapshots, reports violations
    
     # Lock current snapshots as known-good baseline
     mix excessibility.baseline
    
     # After making UI changes, run tests again, then compare
     mix test
     mix excessibility.compare   # Review diffs, choose good (baseline) or bad (new)
    

Usage

use Excessibility

html_snapshot(conn,
  name: "homepage.html",
  screenshot?: true
)

The html_snapshot/2 macro works with:

It returns the source unchanged, so you can use it in pipelines.

Options

OptionTypeDefaultDescription
:namestringauto-generatedCustom filename (e.g., "login_form.html"). Default is ModuleName_LineNumber.html
:screenshot?booleanfalseGenerate PNG screenshots (requires ChromicPDF)
:open_browser?booleanfalseOpen the snapshot in your browser after writing
:cleanup?booleanfalseDelete existing snapshots for the current test module before writing

Baseline Workflow

Snapshots are saved to test/excessibility/html_snapshots/ and baselines live in test/excessibility/baseline/.

Setting a baseline:

mix excessibility.baseline

This copies all current snapshots to the baseline directory. Run this when your snapshots represent a known-good, accessible state.

Comparing against baseline:

mix excessibility.compare

For each snapshot that differs from its baseline:

  1. Diff files are created.good.html (baseline) and .bad.html (new)
  2. Both open in your browser for visual comparison
  3. You choose which to keep — "good" to reject changes, "bad" to accept as new baseline
  4. Diff files are cleaned up after resolution

Batch options:

mix excessibility.compare --keep good   # Keep all baselines (reject all changes)
mix excessibility.compare --keep bad    # Accept all new versions as baseline

Configuration

All configuration goes in test/test_helper.exs or config/test.exs:

Config KeyRequiredDefaultDescription
:endpointYesYour Phoenix endpoint module (e.g., MyAppWeb.Endpoint)
:system_modNoExcessibility.SystemModule for system commands (mockable)
:browser_modNoWallaby.BrowserModule for browser interactions
:live_view_modNoExcessibility.LiveViewModule for LiveView rendering
:excessibility_output_pathNo"test/excessibility"Base directory for snapshots
:pa11y_pathNoauto-detectedPath to Pa11y executable
:pa11y_configNo"pa11y.json"Path to Pa11y config file
:head_render_pathNo"/"Route used for rendering <head> content

Example:

# test/test_helper.exs
Application.put_env(:excessibility, :endpoint, MyAppWeb.Endpoint)
Application.put_env(:excessibility, :system_mod, Excessibility.System)
Application.put_env(:excessibility, :browser_mod, Wallaby.Browser)
Application.put_env(:excessibility, :live_view_mod, Excessibility.LiveView)
Application.put_env(:excessibility, :excessibility_output_path, "test/accessibility")

ExUnit.start()

Pa11y Configuration

The installer creates a pa11y.json in your project root with sensible defaults for Phoenix/LiveView:

{
  "ignore": [
    "WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2"
  ]
}

The ignored rule (H32.2) is "Form does not contain a submit button" — a common false positive for LiveView forms that use phx-submit without traditional submit buttons.

Add additional rules to ignore as needed for your project:

{
  "ignore": [
    "WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2",
    "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
  ]
}

Screenshots

To enable PNG screenshots, start ChromicPDF in your test helper:

# test/test_helper.exs
{:ok, _} = ChromicPDF.start_link(name: ChromicPDF)

ExUnit.start()

Then use screenshot?: true in your snapshots:

html_snapshot(conn, screenshot?: true)

Screenshots are saved alongside HTML files with .png extension.

Mix Tasks

TaskDescription
mix excessibility.installConfigure config/test.exs, create pa11y.json, install Pa11y via npm
mix excessibilityRun Pa11y against all generated snapshots
mix excessibility.baselineLock current snapshots as baseline
mix excessibility.compareCompare snapshots against baseline, resolve diffs interactively
mix excessibility.compare --keep goodKeep all baseline versions (reject changes)
mix excessibility.compare --keep badAccept all new versions as baseline

CI and Non-Interactive Environments

For CI or headless environments where you don't want interactive prompts or browser opens, mock the system module:

# test/test_helper.exs
Mox.defmock(Excessibility.SystemMock, for: Excessibility.SystemBehaviour)
Application.put_env(:excessibility, :system_mod, Excessibility.SystemMock)

Then stub in your tests:

import Mox

setup :verify_on_exit!

test "snapshot without browser open", %{conn: conn} do
  Excessibility.SystemMock
  |> stub(:open_with_system_cmd, fn _path -> :ok end)

  conn = get(conn, "/")
  html_snapshot(conn, open_browser?: true)  # Won't actually open
end

File Structure

test/
 excessibility/
     html_snapshots/          # Current test snapshots
        MyApp_PageTest_42.html
        MyApp_PageTest_42.png   # (if screenshot?: true)
     baseline/                # Locked baselines (via mix excessibility.baseline)
         MyApp_PageTest_42.html

During mix excessibility.compare, temporary .good.html and .bad.html files are created for diffing, then cleaned up after resolution.

License

MIT © Andrew Moore