Testing LiveViews
Prerequisites
Assemble the application routes under test and identify whether the expected behavior belongs to the initial HTTP render or the connected socket.
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
ConnectedRenderobject ConnectedRenderJoins LiveViews through production route admission and connection supervision without starting a network server. to join through production admission and supervision, then interact through a typedConnectedViewclass ConnectedView[-Msg]A semantic handle to one connected root or nested LiveView..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. Use a real browser when the claim depends on DOM patching, JavaScript hooks, focus, uploads, navigation, transport loss, or reconnect behavior.
Test Disconnected Rendering
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:
def repositories = Seq(
"https://central.sonatype.com/repository/maven-snapshots"
)
def mvnDeps = Seq(
mvn"dev.scalive::scalive-testing:0.0.1-e7428c947796-SNAPSHOT"
)The DisconnectedRender.runobject DisconnectedRenderRuns serverless tests against the first, disconnected HTTP render. 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.
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
).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 ProfilePageSpecThe names Routes.profile and ProfileLiveView represent application code. Use
a fixed, valid test ZioHttpConfigclass ZioHttpConfigValidated security configuration for the ZIO HTTP transport.
when assertions depend on signed cookies or tokens; do not compare output
produced with independently constructed configurations.
Query Forms Semantically
The RenderedPageclass RenderedPageA response and semantic view of its Jsoup-parsed HTML. exposes the status and
headers through responseval response: zio.http.ResponseThe route response with its body replaced by a replayable body containing html., the exact body through
htmlval html: StringThe complete decoded response body before Jsoup parsing., normalized document
text through textdef text: StringReturns the parsed document's decoded, combined text with whitespace normalized by Jsoup., and all forms through
formsdef forms: Vector[testing.RenderedForm]Returns every parsed form element in DOM order..
formdef form(query: testing.FormQuery = ...): Either[testing.FormQueryError, testing.RenderedForm]Selects exactly one form matching query. 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 RenderedFormclass RenderedFormA semantic view of one form in a parsed disconnected-render snapshot. exposes its
iddef id: Option[String]Returns the parsed id attribute, preserving absent versus present-empty.,
actiondef action: Option[String]Returns the parsed, unresolved action attribute.,
methoddef method: zio.http.MethodReturns POST for a case-insensitive post attribute and GET otherwise.,
fieldsdef fields: Vector[testing.RenderedField]Returns named descendant controls in DOM order., and
valuesdef values(name: String): Vector[String]. It also reports
phx-changedef hasChangeBinding: BooleanReports whether the form has a phx-change attribute.,
phx-submitdef hasSubmitBinding: BooleanReports whether the form has a phx-submit attribute., and
phx-trigger-actiondef triggersAction: BooleanReports whether the form has a phx-trigger-action attribute. presence.
RenderedFieldclass RenderedFieldA named button, input, select, or textarea in a parsed form snapshot. 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
LiveViewtrait LiveView[Msg, Model]Defines a server-rendered view with typed messages and model state. event.
Use FormPathclass FormPath(segments: Vector[String])A structured form field path rendered with browser bracket notation. for generated nested names when the application uses a
FormCodectrait FormCodec[A]Decodes and validates FormData as a value of type A.. Values remain a Vector because repeated names, such as checkbox
groups, are valid.
Submit Ordinary Forms
Use RenderedForm.submitdef submit[R](routes: zio.http.Routes[R, Nothing], data: FormData, submitter: Option[FormSubmitter] = ...): zio.ZIO[R, Throwable, testing.RenderedPage]Submits explicit ordered fields to this form's local ordinary HTTP action.
when a rendered GET or POST form should execute an ordinary local HTTP route.
Supply the complete ordered FormDataclass FormDataAn ordered browser form payload that preserves duplicate textual fields.,
including the rendered CSRF field for a checked POST:
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(FormSubmitter("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:
followSeeOtherdef followSeeOther[R](routes: zio.http.Routes[R, Nothing]): zio.ZIO[R, Throwable, testing.RenderedPage]Follows this response's local 303 See Other location with a GET request.
accepts only a local 303 See Other with a Location header.
Cover Connected Behavior
ConnectedRender.joindef join[Msg, Model](liveView: LiveView[Msg, Model]): zio.package.RIO[zio.Scope, testing.ConnectedView[Msg]]
finalizes a single LiveView at /, performs disconnected rendering, validates
its bootstrap credentials, and starts the connected lifecycle through production
route admission and supervision:
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 view.send(message) when a typed server message is the behavior under test.
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
joinNested(instanceId) enters a nested lifecycle registered by the parent.
For routed applications, use the overload accepting LiveApplication, a fixed
validated ZioHttpConfig, a ZIO HTTP Request, and optional untrusted connect
parameters. That overload exercises the actual route, mount aspects, layouts,
security bootstrap, and request URL rather than mounting a synthetic root.
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, or prove browser history
behavior. Keep those claims at the browser boundary.
Test In A Browser
Run the same production-shaped server, asset bundle, 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;
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; and
a deliberately interrupted WebSocket exercises the application's reconnect expectations.
The Scalive repository runs the upstream Phoenix LiveView v1.1.28 Playwright
suite against e2eApp with:
./scripts/e2e-run-upstream.shChanges 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 connect. A passing
ConnectedRender test does not prove 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.
Related Tasks
Build the production-shaped browser bundle with Client setup and static assets.
Locate the failing lifecycle stage with Troubleshooting.
Supply deterministic dependencies with Services and dependency injection.