PhoenixTestDatastar
A PhoenixTest driver for Dstar-powered Phoenix applications.
Write feature tests using the same visit, click_button, fill_in, and
assert_has API you already know — PhoenixTestDatastar handles the Datastar
parts: signals, SSE responses, and DOM patching.
test "torpedo launch increments warhead count", %{conn: conn} do
conn
|> visit("/rocinante/weapons")
|> click_button("Fire torpedo")
|> assert_has("#warheads-remaining", text: "4")
endTest real-time SSE streams too — open a long-lived connection, trigger server events, and assert on the updates as they arrive:
alias PhoenixTestDatastar.Stream
test "sensor dashboard updates on new contact", %{conn: conn} do
session =
conn
|> visit("/rocinante/sensors")
|> Stream.open_stream("/ds/sensor_handler/listen")
|> Stream.await_events()
assert_signal(session, "contacts", 0)
# Simulate a server-side event
Phoenix.PubSub.broadcast(Roci.PubSub, "sensors", {:contact_detected, 1})
session
|> Stream.await_events()
|> assert_signal("contacts", 1)
|> assert_has("#contact-count", text: "1")
|> Stream.close_stream()
endNo browser. No JavaScript runtime. Just ExUnit.
Why?
Dstar brings Datastar's reactive UI to Phoenix via Server-Sent Events. It's a different model from both static pages and LiveView:
| Static Pages | LiveView | Dstar | |
|---|---|---|---|
| Transport | HTTP request/response | WebSocket | SSE over HTTP |
| State | Server sessions | Server process | Client-side signals |
| Interaction | Form submits | phx-click | data-on:click="@post(...)" |
| DOM updates | Full page reload | Diff patching via WS |
SSE patch-elements events |
PhoenixTest's static driver understands HTML forms. Its Live driver understands
phx-* bindings. Neither understands data-signals, @post(), or SSE event
streams.
PhoenixTestDatastar is the third driver. It simulates the Datastar JavaScript client inside your test process: maintaining signal state, dispatching HTTP requests, parsing SSE responses, and applying patches to an in-memory DOM.
Installation
Add phoenix_test_datastar to your test dependencies in mix.exs:
def deps do
[
{:phoenix_test_datastar, "~> 0.0.1", only: :test, runtime: false}
]
endConfiguration
PhoenixTestDatastar uses the same endpoint config as PhoenixTest. In
config/test.exs:
config :phoenix_test, :endpoint, RociWeb.EndpointSetup
Create a DatastarCase helper in test/support/datastar_case.ex:
defmodule RociWeb.DatastarCase do
use ExUnit.CaseTemplate
using do
quote do
import PhoenixTest
import PhoenixTestDatastar
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(
Roci.Repo, shared: not tags[:async]
)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
conn =
Phoenix.ConnTest.build_conn()
|> PhoenixTest.put_endpoint(RociWeb.Endpoint)
{:ok, conn: conn}
end
end
Then in your tests, use PhoenixTestDatastar.visit/2 as the entry point:
test "my test", %{conn: conn} do
conn
|> PhoenixTestDatastar.visit("/some-page")
|> click_button("Do something")
|> assert_has("#result", text: "Done")
endNote: Use
PhoenixTestDatastar.visit/2instead ofPhoenixTest.visit/2. This returns aPhoenixTestDatastar.Sessionstruct that routes through the Datastar driver. All subsequentclick_button,fill_in,assert_hasetc. calls work through PhoenixTest's standard API.
Usage
Clicking buttons
Datastar buttons use data-on:click="@post(...)" instead of form submissions.
PhoenixTestDatastar detects these automatically:
test "adjust reactor output", %{conn: conn} do
conn
|> visit("/rocinante/engineering")
|> click_button("Increase thrust")
|> click_button("Increase thrust")
|> assert_has("#reactor-output", text: "75%")
|> click_button("Decrease thrust")
|> assert_has("#reactor-output", text: "50%")
end
When you call click_button, the driver:
- Finds the button in the DOM
-
Reads its
data-on:clickattribute (e.g.,@post('/ds/engineering_events/increase_thrust')) - Builds a POST request with the current signals as JSON body
- Dispatches through your endpoint
-
Parses the SSE response (
patch-signals,patch-elements) - Applies patches to the in-memory DOM and signal state
Standard form buttons (without Datastar attributes) fall back to regular form submission — so pages that mix Datastar and traditional forms work correctly.
Filling in forms
Datastar inputs use data-bind to bind to signals. PhoenixTestDatastar handles
both signal-bound and traditional form inputs:
test "search filters crew roster", %{conn: conn} do
conn
|> visit("/rocinante/crew")
|> fill_in("Search", with: "Holden")
|> click_button("Filter")
|> assert_has(".crew-member", text: "James Holden")
|> refute_has(".crew-member", text: "Amos Burton")
end
For data-bind inputs, fill_in updates the signal directly. For traditional
inputs, it tracks the value for form submission — same as PhoenixTest's static
driver.
Assertions
All standard PhoenixTest assertions work:
conn
|> visit("/ops/dashboard")
|> assert_has("h1", text: "OPS Dashboard")
|> assert_has("#crew-count", text: "4")
|> refute_has(".hull-breach")
|> assert_path("/ops/dashboard")Signal assertions
Import PhoenixTestDatastar.Assertions for signal-aware assertions:
import PhoenixTestDatastar.Assertions
test "torpedo launch decrements warhead count", %{conn: conn} do
conn
|> visit("/rocinante/weapons")
|> assert_signal("warheads", 5)
|> assert_signal_set("warheads")
|> refute_signal("nonexistent")
|> click_button("Fire torpedo")
|> assert_signal("warheads", 4)
endNavigation and redirects
Dstar redirects work via Dstar.redirect/2, which sends a script that sets
window.location.href. The driver detects these and follows the redirect:
test "login redirects to bridge", %{conn: conn} do
conn
|> visit("/login")
|> fill_in("Callsign", with: "holden@rocinante.belt")
|> fill_in("Access code", with: "donnager-7")
|> click_button("Authenticate")
|> assert_path("/bridge")
|> assert_has("h1", text: "Welcome aboard, Captain")
end
Scoping with within
When a page has multiple forms or repeated elements, scope your interactions:
test "repair specific ship system", %{conn: conn} do
conn
|> visit("/rocinante/damage-report")
|> within("#system-pdc-array", fn session ->
session
|> click_button("Repair")
end)
|> assert_has("#system-pdc-array.operational")
end
Debugging with open_browser
Inspect the current DOM state in your browser:
conn
|> visit("/rocinante/weapons")
|> click_button("Fire torpedo")
|> open_browser() # opens the current HTML in your default browser
|> click_button("Fire torpedo")Real-time SSE streams
For handlers that enter long-lived receive loops (e.g., PubSub-driven updates), use the streaming API:
alias PhoenixTestDatastar.Stream
test "live dashboard updates on sensor change", %{conn: conn} do
session =
conn
|> visit("/rocinante/sensors")
|> Stream.open_stream("/ds/sensor_handler/listen")
|> Stream.await_events()
assert_signal(session, "contacts", 0)
# Simulate external event (e.g., PubSub broadcast)
Phoenix.PubSub.broadcast(Roci.PubSub, "sensors", {:contact_detected, 1})
session
|> Stream.await_events()
|> assert_signal("contacts", 1)
|> Stream.close_stream()
enddata-init auto-dispatching
When visiting a page with data-init attributes, the driver automatically
dispatches the init actions — just like the Datastar JS client would:
# If the page has: <div data-init="@get('/ds/dashboard/load')">
test "dashboard loads initial data on visit", %{conn: conn} do
conn
|> visit("/dashboard") # data-init actions fire automatically
|> assert_has("#stats", text: "42")
end
Escape hatch with unwrap
Access the raw conn when you need it:
conn
|> visit("/rocinante/weapons")
|> unwrap(fn conn ->
# do something with the raw conn
conn
end)How it works
┌──────────────────────────────────────────────────────────────────┐
│ Test Code │
│ conn |> visit("/rocinante/weapons") |> click_button("Fire") │
│ |> assert_has("#warheads-remaining", text: "4") │
└────────────────────┬─────────────────────────────────────────────┘
│ PhoenixTest.Driver protocol
┌────────────────▼────────────────┐
│ PhoenixTestDatastar.Session │
│ │
│ • Signal Store (%{warheads: 5}) │
│ • DOM (in-memory HTML) │
│ • SSE Parser │
│ • Action Dispatcher │
└────────────────┬────────────────┘
│ Phoenix.ConnTest.dispatch
┌────────────────▼────────────────┐
│ Your Phoenix Endpoint │
│ Router → Dstar Handlers │
└─────────────────────────────────┘
On visit/2, the driver makes a standard GET request, extracts signals from
data-signals attributes, and stores the HTML — like pulling up the Roci's
tactical display.
On click_button/2, it finds the Datastar action expression, POSTs the current
signals as JSON, parses the SSE response, and applies patch-signals and
patch-elements events to update state — like the CIC processing a fire
command.
Assertions query the in-memory DOM — no network round-trip needed.
Supported PhoenixTest API
PhoenixTestDatastar implements the full PhoenixTest.Driver protocol:
| Function | Datastar behavior |
|---|---|
visit/2 |
GET request, extract signals from data-signals attributes |
click_button/2,3 |
Detect data-on:click, dispatch @post/@get, apply SSE |
click_link/2,3 |
Detect data-on:click or follow href |
fill_in/3,4 |
Update signal (if data-bind) or track form value |
select/3,4 | Update signal or track selection |
check/2,3 | Update signal or track checkbox |
uncheck/2,3 | Update signal or track checkbox |
choose/2,3 | Update signal or track radio |
submit/1 | Submit active form |
within/3 | Scope to CSS selector |
assert_has/2,3 | Query in-memory DOM |
refute_has/2,3 | Query in-memory DOM |
assert_path/2,3 | Check current path |
refute_path/2,3 | Check current path |
open_browser/1 | Open HTML in system browser |
unwrap/2 | Access raw conn |
reload_page/1 | Re-visit current path |
Datastar-specific API
| Function | Description |
|---|---|
PhoenixTestDatastar.visit/2 | Entry point — creates Datastar session |
PhoenixTestDatastar.get_signal/2 | Read a signal value |
PhoenixTestDatastar.get_signals/1 | Read all signals |
PhoenixTestDatastar.put_signal/3 | Set a signal (for test setup) |
PhoenixTestDatastar.Assertions.assert_signal/3 | Assert signal value |
PhoenixTestDatastar.Assertions.assert_signal_set/2 | Assert signal exists |
PhoenixTestDatastar.Assertions.refute_signal/2 | Assert signal absent |
PhoenixTestDatastar.Stream.open_stream/2 | Open SSE stream connection |
PhoenixTestDatastar.Stream.await_events/1 | Wait for and apply SSE events |
PhoenixTestDatastar.Stream.close_stream/1 | Close SSE stream |
Dependencies
- phoenix_test — Driver protocol and test helpers
- phoenix — ConnTest dispatching
- floki — DOM parsing and patching
- jason — JSON encoding/decoding
Dstar itself is not a dependency. The driver only understands the Datastar SSE wire format and HTML attribute conventions. This keeps the packages loosely coupled and means PhoenixTestDatastar works with any Elixir library that speaks the Datastar protocol.
License
MIT