Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Testing LiveViews

Before You Start

Start with one observable behavior or incident to reproduce, the application routes that expose it, and a decision about whether it belongs to initial HTTP rendering, the connected socket, or the browser.

Choose The Test Boundary

Scalive applications have three useful test boundaries:

  • Disconnected tests execute the finalized ZIO HTTP routes and inspect the first HTML response. This boundary has public support in scalive.testing.

  • Connected tests use ConnectedRender to run an isolated root or routed application through signed bootstrap, Phoenix transport dispatch, and lifecycle supervision, then interact through a typed ConnectedView. Routed joins also exercise application routes and transport-owned withAdmission.

  • Browser tests run the Phoenix LiveView JavaScript client against a real server. Scalive does not currently publish a browser fixture or Playwright library.

Use the narrowest boundary that proves the behavior. Keep most rendering, routing, form, cookie, and initial lifecycle assertions disconnected. Connected tests can cover server-side navigation, transport invalidation, and reconnect admission. Use a real browser when the claim depends on DOM patching, JavaScript hooks, focus, browser history, reconnect timing, or network behavior.

Add Testing Support

Scalive publishes and supports exactly two Scala coordinates: dev.scalive::scalive, containing all production API, render, runtime, protocol, and transport classes, and dev.scalive::scalive-testing for optional test support. Add the latter to the test module that already depends on your application. Inside this repository, Mill modules use moduleDeps = Seq(..., scalive.testing). External snapshot consumers use the same snapshot repository and revision as the application artifact:

scala
def repositories = Seq(
  "https://central.sonatype.com/repository/maven-snapshots"
)

def mvnDeps = Seq(
  mvn"dev.scalive::scalive-testing:0.0.1-341a561ee032-SNAPSHOT"
)

Test Disconnected Rendering

The DisconnectedRender.run method accepts finalized Routes and a ZIO HTTP Request. It runs the route, consumes the response body once, restores a replayable body, and parses the HTML with jsoup:

The example uses ZIOSpecDefault for the test runtime, suite to group tests, and test for an effectful assertion. orDieWith turns an unexpected typed failure into a test defect with a useful assertion error.

scala
import zio.*
import zio.http.*
import zio.test.*

import scalive.*
import scalive.testing.*

object ProfilePageSpec extends ZIOSpecDefault:
  private val config = ZioHttpConfig(
    signingSecret = "fixed-test-signing-secret-000000000000",
    sessionMaxAge = java.time.Duration.ofMinutes(30),
    secureCookie = false,
    allowedWebSocketOrigins = Set(WebSocketOrigin.http("localhost"))
  ).fold(error => throw IllegalArgumentException(error.toString), identity)

  private val application = Live.router(Routes.profile -> ProfileLiveView())
  private val routes = ZioHttp.routes(application, config)

  def spec = suite("ProfilePageSpec")(
    test("renders the profile form") {
      for
        page <- DisconnectedRender.run(routes, Request.get(URL.root))
        profileForm <- ZIO
                         .fromEither(
                           page.form(
                             FormQuery(
                               action = Some("/profiles"),
                               method = Some(Method.POST)
                             )
                           )
                         )
                         .orDieWith(error => new AssertionError(error.toString))
      yield assertTrue(
        page.response.status == Status.Ok,
        page.text.contains("Profile"),
        profileForm.hasSubmitBinding,
        profileForm.values(FormPath("profile", "name")) == Vector("Alice")
      )
    }
  )
end ProfilePageSpec

The names Routes.profile and ProfileLiveView represent application code. Use a fixed, valid test ZioHttpConfig when assertions depend on signed cookies or tokens; do not compare output produced with independently constructed configurations. Even disconnected tests must supply a non-empty origin allowlist because it is part of valid transport configuration.

Query Forms Semantically

The RenderedPage exposes the status and headers through response, the exact body through html, normalized document text through text, and all forms through forms. form succeeds only when exactly one form matches its optional action and method filters. Handle NotFound and MultipleMatches instead of silently selecting the first form.

The RenderedForm exposes its id, action, method, fields, and values. It also reports phx-change, phx-submit, and phx-trigger-action presence. RenderedField exposes tag name, id, name, value, input type, and required state. These are HTML queries; they do not automatically choose submitted controls or dispatch a LiveView event.

Use FormPath for generated nested names when the application uses a FormCodec. Values remain a Vector because repeated names, such as checkbox groups, are valid.

Submit Ordinary Forms

Use RenderedForm.submit when a rendered GET or POST form should execute an ordinary local HTTP route. Supply the complete ordered FormData, including the rendered CSRF field for a checked POST:

scala
for
  page <- DisconnectedRender.run(liveRoutes, Request.get(loginUrl))
  form <- ZIO.fromEither(page.form(FormQuery(method = Some(Method.POST))))
  csrf <- ZIO.fromOption(form.values(CsrfProtection.ParamName).headOption)
  redirect <- form.submit(
                httpRoutes,
                FormData(
                  Vector(
                    CsrfProtection.ParamName -> csrf,
                    LoginForm.Email.name      -> "ada@example.test"
                  )
                ),
                 submitter = Some(RawFormSubmitter("sign-in", "yes"))
              )
  dashboard <- redirect.followSeeOther(liveRoutes)
yield assertTrue(dashboard.text.contains("Welcome"))

Submission retains duplicate field names and appends the optional submitter. GET fields replace the action query. POST fields become an application/x-www-form-urlencoded body while the action query is retained. Other POST encodings fail explicitly. Relative actions honor the document's first base[href]; same-origin absolute actions are accepted, while cross-origin actions fail because the serverless harness executes only the supplied routes.

Cookies returned by one response are carried by name into the next request, and zero-Max-Age cookies are removed. This intentionally supports Scalive's root-scoped test flows rather than simulating browser domain, path, Secure, or SameSite policy. Redirects remain explicit: followSeeOther accepts only a local 303 See Other with a Location header.

Cover Connected Behavior

Join An Isolated LiveView

ConnectedRender.join finalizes a single LiveView at /, performs disconnected rendering, validates its bootstrap credentials, and starts the connected lifecycle through the production in-process Phoenix transport and supervision:

scala
test("increments after a connected click") {
  ZIO.scoped {
    for
      view  <- ConnectedRender.join(CounterLiveView())
      _     <- view.clickButton("Increment")
      count <- view.text("#count")
    yield assertTrue(count == "1")
  }
}

Use this overload when routing and reconnect admission are not part of the claim. The name CounterLiveView represents application code.

Open A Routed Application Client

For routed behavior, use ConnectedRender.open with a LiveApplication, a fixed validated ZioHttpConfig, and the request that should render the route. It performs the disconnected request and returns a ConnectedClient whose join and reconnect operations each create a distinct in-process transport:

scala
ZIO.scoped {
  for
    client <- ConnectedRender.open(
                application,
                config,
                Request.get(URL.decode("/accounts/42").fold(throw _, identity))
              )
    view   <- client.join
    title  <- view.text("h1")
  yield assertTrue(title == "Account 42")
}

Here application and its /accounts/42 route are application code. This form exercises the actual route, mount aspects, layouts, signed bootstrap, request URL, Phoenix event dispatcher, and any root admission installed with withAdmission. That transport-owned boundary is sometimes called physical admission. It revalidates every root join before installing its connected lifecycle, including reconnects and followed same-session navigation; reconnect also creates a new transport identity.

Optional connectParams are untrusted client JSON, not authenticated session or request data. The harness owns _mounts, overwrites any supplied value, starts it at 0, and advances it for each root reconnect or followed navigation. Applications should not treat Phoenix-owned keys as a stable contract.

Exercise Actions And Navigation

Use view.send(message) when a typed server message is the behavior under test. Typed messages are an out-of-band test operation because arbitrary Scala values have no Phoenix wire representation; their resulting lifecycle output still uses the production transport sink. Use click, clickButton, changeForm, and submitForm to resolve bindings from the latest committed HTML and wait for the correlated lifecycle output. awaitDiff waits for an uncorrelated async or subscription update, and awaitAction waits for uncorrelated navigation or transport disconnect. The joinNested(instanceId) method enters a nested lifecycle registered by the parent. Actions return a ConnectedAction:

CaseMeaning
RenderedThe action completed without terminal navigation.
LiveNavigation(navigation)A same-session route replacement is available to follow explicitly.
Redirect(to)A full redirect was emitted and the in-process transport closed.
DisconnectedThe transport closed before the correlated reply arrived.

Wait For An HTML Condition

Use awaitHtml for async work that may produce several intermediate renders. It returns a Task[String] containing the exact HTML that satisfied the predicate; inspect that returned snapshot rather than reading view.html again:

scala
import scalive.testing.{HtmlQueryError, RenderedHtml}

for
  _ <- view.clickButton("Run report")
  matched <- view.awaitHtml("report succeeds", java.time.Duration.ofSeconds(10)) { html =>
               RenderedHtml.parse(html).selectOne("[data-report-status]") match
                 case Right(status) => status.text == "Succeeded"
                 case Left(HtmlQueryError.NotFound(_) | HtmlQueryError.MultipleMatches(_, _)) =>
                   false
                 case Left(error @ HtmlQueryError.InvalidSelector(_, _)) =>
                   throw IllegalArgumentException(error.toString)
             }
yield assertTrue(
  RenderedHtml.parse(matched).selectOne("[data-report-status]").map(_.text) == Right("Succeeded")
)

The button and selector are application-defined. awaitHtml checks current HTML first, then rechecks after uncorrelated async diff notifications. One overall deadline covers the wait: intermediate diffs do not restart it, and it is not limited by awaitDiff's five-second timeout. A java.util.concurrent.TimeoutException includes the description, requested duration, and last observed HTML. Predicate exceptions fail the task. Keep the predicate quick and nonblocking. Retirement or disconnection fails the wait; it never follows navigation to another view. In this predicate, missing or multiple status elements mean not ready; an invalid selector fails immediately instead of being hidden until timeout.

The deadline uses the current ZIO clock. In ZIOSpecDefault this is TestClock: advance it explicitly for deterministic deadline tests, or wrap just the wait in zio.test.Live.live(view.awaitHtml(description, timeout)(predicate)) for a wall-clock timeout without changing the clock used by application work already running.

Only one waiter may consume a view's diff queue: do not run awaitHtml and awaitDiff concurrently, or multiple awaitHtml calls on the same view. Finish correlated click, send, and form actions before waiting. Each check observes the latest semantic server projection, so transient states can be missed. This is not browser simulation and does not execute JavaScript or prove DOM patching.

Inspect Retained HTML Snapshots

Use RenderedHtml.parse(html) to query any retained HTML string, including view.html, the result of awaitHtml, or a disconnected page's body. A RenderedHtml keeps the exact input in html, not a reserialized document; text is normalized whole-document text. Parse old strings directly rather than querying the current live view:

scala
import scalive.testing.{ConnectedRender, HtmlQueryError, RenderedHtml}

for
  retained <- ZIO.scoped {
                for
                  view   <- ConnectedRender.join(CounterLiveView())
                  before <- view.html
                  _      <- view.clickButton("Increment")
                  after  <- view.html
                yield (before, after)
              }
yield
  // The connected view is already closed; both strings remain queryable.
  val before = RenderedHtml.parse(retained._1)
  val after  = RenderedHtml.parse(retained._2)
  assertTrue(
    before.html == retained._1,
    before.selectOne("#count").map(_.text) == Right("0"),
    after.selectOne("#count").map(_.text) == Right("1"),
    before.selectOne("#missing") == Left(HtmlQueryError.NotFound("#missing")),
    after.selectAll("#missing").map(_.isEmpty) == Right(true)
  )

CounterLiveView represents application code with an initial count of zero and no #missing element. CSS queries use jsoup selector syntax:

  • selectOne(css) returns Either[HtmlQueryError, RenderedElement] and requires exactly one match. Zero matches produce NotFound(selector); more than one produces MultipleMatches(selector, count).

  • selectAll(css) returns Either[HtmlQueryError, Vector[RenderedElement]], including Right(Vector.empty) for no matches.

  • Both return HtmlQueryError's InvalidSelector(selector, message) for malformed selectors. Assert on the Either or handle its errors explicitly rather than hiding failures in defaults.

Each RenderedElement exposes read-only tagName, Jsoup-normalized text, and value. attribute(name) is case-insensitive and returns entity-decoded values: None means absent, while Some("") means present but empty. Relative URL attributes stay relative; lookup does not resolve them against a base URL. value follows the existing RenderedField semantics: textarea text has outer whitespace trimmed but retains internal whitespace; other elements return their own value attribute or "" if absent. It does not derive a select's value from selected options or supply a checkbox's browser-default "on" value.

Snapshots and query results are immutable and remain valid after later renders, navigation, or harness closure. Every query uses an isolated working document, so selector evaluation cannot change other queries or retained results. These are semantic server-HTML assertions only: they do not execute JavaScript, simulate successful-control selection, or prove browser DOM patching.

Test Typed Form Behavior

Use changeForm with a target and _unused_* markers to verify field-local feedback. Use submitForm without unused markers to verify that submission reveals all errors and produces a domain value only when valid. Query the committed HTML after each action rather than asserting against an independently decoded form:

scala
for
  view <- ConnectedRender.join(ProfileLiveView())
  _ <- view.changeForm(
         "#profile-form",
         Vector(
           Profile.Name.name        -> "",
           "profile[_unused_email]" -> "",
           Profile.Email.name       -> ""
         ),
         target = Some(Profile.Name.name)
       )
  changed <- view.html
  _ <- view.submitForm(
         "#profile-form",
         Vector(Profile.Name.name -> "", Profile.Email.name -> "invalid")
       )
  submitted <- view.html
yield assertTrue(
  changed.contains("Name is required"),
  !changed.contains("Enter a valid email"),
submitted.contains("Enter a valid email")
)

For a definition-owned typed submitter, pass its raw successful-control pair explicitly. The connected harness appends that pair to the ordered payload just as the Phoenix browser serializer does; it does not synthesize optional protocol metadata:

scala
view.submitForm(
  "#profile-form",
  validFields,
  submitter = Some(Profile.Submitter.raw(Profile.Intent.Preview))
)

This exercises the same FormData decoder as a real button click. Use a browser test when the claim also depends on native implicit submission, form association, constraint validation, or JavaScript loading behavior.

Repeated forms must submit every row's presence control along with its fields. Exercise add, remove, and reorder controls through the connected view, then assert stable row keys rather than display indexes. The repeated contacts example demonstrates the complete interaction. An isolated ConnectedView is enough for ordinary change and submit behavior. ConnectedClient.reconnect proves server-side transport admission and lifecycle behavior, but it does not run Phoenix JavaScript or replay browser form values. Verify automatic form recovery in a browser test, or dispatch an explicit recovery payload at a lower protocol boundary when that protocol behavior is the claim.

A LiveNavigation contains a ConnectedNavigation. Inspect its destination and replace values, then call follow to execute the production redirect-join admission path:

scala
for
  action <- view.click("[data-open-settings]")
  settings <- action match
                case ConnectedAction.LiveNavigation(navigation) =>
                  navigation.follow
                case other =>
                  ZIO.fail(new AssertionError(s"Expected live navigation, got $other"))
  heading <- settings.text("h1")
yield assertTrue(heading == "Settings")

The selector and destination route in this example belong to the application.

Test Reconnect Admission

ConnectedClient retains the page's signed bootstrap credentials, so reconnect tests do not issue another disconnected GET. Revoke durable authorization, retire the admitted transport, wait until the view observes disconnection, and assert that the next transport is rejected:

scala
val request = Request.get(
  URL.decode("/accounts/42?session=test-session").fold(throw _, identity)
)

ZIO.scoped {
  for
    client <- ConnectedRender.open(admittedApplication, config, request)
    view   <- client.join
    _      <- authorization.revoke(sessionId)
    _      <- connections.disconnect(sessionId)
    _      <- view.awaitDisconnected
    result <- client.reconnect.either
  yield assertTrue(result == Left(ConnectedJoinFailure.Unauthorized))
}

The /accounts/42 route, admittedApplication, authorization.revoke, sessionId, and the LiveConnections value named connections are application test setup. The application installs its admission with withAdmission; the example does not define an application authorization API. Provide the layers required by admittedApplication around this scoped effect as described in Services and dependency injection.

Join and follow failures use ConnectedJoinFailure:

CaseMeaning
UnauthorizedSigned join authorization or connected mount admission rejected the join.
StaleThe server requires a fresh disconnected render and bootstrap.
DisconnectedThe transport closed while the join was pending.
Redirect(to)Connected mount requested a redirect instead of installing the view.
Transport(error)The in-process transport failed outside a protocol-visible join result.

This proves server-side admission is rerun for a fresh transport. It does not prove when or how often a browser retries after network loss.

Keep Connected Harness Boundaries Explicit

The in-process transport executes the same transport-owned join, admission, event, hosted-upload, leave, and navigation state as the WebSocket adapter. The upload helper supports hosted uploads only, not external uploaders. The harness does not call the WebSocket endpoint, serialize network frames, or validate the upgrade request's Origin header.

ConnectedView.html is a semantic projection of the latest committed server render, not a browser DOM. Connected tests do not execute the Phoenix JavaScript client, patch a real document, run hooks, manage focus, schedule browser reconnect attempts, or prove browser history behavior. Keep those claims at the browser boundary.

Test WebSocket Origin Admission

Exercise the finalized ZIO HTTP routes when the upgrade policy itself is the behavior under test. This boundary can prove that one configured origin produces an upgrade response and a missing origin receives HTTP 403 without starting a server or browser:

scala
val socketUrl = URL.decode("/live/websocket").fold(throw _, identity)
val allowedUpgrade = Request
  .get(socketUrl)
  .addHeader(Header.Custom("origin", "http://localhost"))

test("admits only a configured websocket origin") {
  for
    admitted <- ZIO.scoped(routes.runZIO(allowedUpgrade))
    rejected <- ZIO.scoped(routes.runZIO(Request.get(socketUrl)))
  yield assertTrue(
    admitted.status == Status.SwitchingProtocols,
    rejected.status == Status.Forbidden
  )
}

The request origin must match the allowedWebSocketOrigins used to construct routes. Use transport or edge integration tests for duplicate physical headers and proxy behavior; those shapes are not reliably produced by browser automation.

Test In A Browser

Run the same production-shaped server, browser assets, root layout, security configuration, and socket path that users will receive. A browser smoke suite should prove at least:

  • the disconnected document contains meaningful content before the socket joins;

  • the LiveSocket reaches the connected state;

  • the server admits the page's exact configured HTTP or HTTPS Origin;

  • one event updates the existing DOM;

  • live patch or navigation preserves the expected URL and title;

  • hooks and uploads work in a real browser when the application uses them;

  • controls expose semantic elements, visible labels, and accessible names;

  • keyboard-only interaction follows the intended focus order and visibly shows focus;

  • validation, status, and other live feedback is announced when appropriate, with only intentional focus moves such as focusing an error summary; and

  • a deliberately interrupted WebSocket exercises the application's reconnect expectations.

At the transport request boundary, separately verify that missing, null, malformed, duplicate, combined, and mismatched Origin values receive HTTP 403 before upgrade. Browser automation cannot normally manufacture all of those forbidden header shapes, so keep these as edge or transport integration tests.

The Scalive repository runs the upstream Phoenix LiveView v1.2.10 Playwright suite against e2eApp with:

bash
./scripts/e2e-run-upstream.sh

Changes to the runtime, protocol, transport, or synchronized fixtures can use ./scripts/e2e-run-upstream-strict.sh to require three complete consecutive runs with retries disabled.

These scripts, their test/playwright.upstream.config.js, and the repository's site Playwright suites are project regression infrastructure. They are evidence for the compatibility matrix, not a distributed browser-testing API for Scalive applications. Application teams should own selectors, fixtures, server startup, and assertions for their product.

Know What Each Test Proves

A passing disconnected test does not prove a socket can join. A passing ConnectedRender test proves the server-side transport session but neither WebSocket Origin admission nor that the Phoenix client can patch the browser DOM. A browser test proves its scenario but may not isolate the failing lifecycle stage. Keep at least one assertion at each boundary your application depends on, and use Troubleshooting to locate a failure before expanding the test suite.