Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Testing your LiveView

Add a test module

Scalive's test support is the optional dev.scalive::scalive-testing artifact. Add a test module inside app in build.mill. It uses the same snapshot repository and version as the application:

scala
//| mill-version: 1.3.0
package build

import mill.*, scalalib.*

object app extends ScalaModule:
  def scalaVersion = "3.10.0"
  def mainClass = Some("quickstart.Main")

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

  def mvnDeps = Seq(
    mvn"dev.scalive::scalive:0.0.1-048a349b1d9d-SNAPSHOT"
  )

  object test extends ScalaTests with TestModule.ZioTest:
    def zioTestVersion = "2.1.26"

    def mvnDeps = Seq(
      mvn"dev.scalive::scalive-testing:0.0.1-048a349b1d9d-SNAPSHOT"
    )
end app

TestModule.ZioTest runs tests with ZIO Test. The nested test module depends on app, so tests can use the application's classes. Put test sources under app/test/src, in the same package as the application:

text
scalive-quick-start/
└── app/
    ├── src/
    │   └── quickstart/
    └── test/
        └── src/
            └── quickstart/
                ├── CounterLiveViewSpec.scala
                ├── SignupLiveViewSpec.scala
                └── ThermostatLiveViewSpec.scala

Write a first test

Create app/test/src/quickstart/CounterLiveViewSpec.scala:

Source
scala
package quickstart

import zio.*
import zio.test.*

import scalive.testing.*

object CounterLiveViewSpec extends ZIOSpecDefault:
  def spec = suite("CounterLiveView")(
    test("increases the count, then resets it") {
      ZIO.scoped {
        for
          view      <- ConnectedRender.join(CounterLiveView())
          initial   <- view.text("output")
          _         <- view.clickButton("Increase")
          _         <- view.clickButton("Increase")
          increased <- view.text("output")
          _         <- view.clickButton("Reset")
          reset     <- view.text("output")
        yield assertTrue(initial == "0", increased == "2", reset == "0")
      }
    }
  )

The spec extends ZIOSpecDefault, groups tests with suite, and asserts with assertTrue. ConnectedRender.join mounts the LiveView, renders its first HTML, and then connects it through the same protocol handling a browser uses, without starting a server. clickButton finds the button labeled Increase in the latest HTML, sends its click, and waits for the next render. text reads one element by CSS selector, here the counter's <output>. ZIO.scoped closes the connected view when the test ends.

Run the tests from the project root:

bash
mill app.test

Mill compiles the application and its tests, then reports increases the count, then resets it as passed.

Test a form

Create app/test/src/quickstart/SignupLiveViewSpec.scala:

Source
scala
package quickstart

import zio.*
import zio.test.*

import scalive.testing.*

object SignupLiveViewSpec extends ZIOSpecDefault:
  def spec = suite("SignupLiveView")(
    test("shows every error when an invalid form is submitted") {
      ZIO.scoped {
        for
          view <- ConnectedRender.join(SignupLiveView())
          _    <- view.submitForm(
                    "#signup-form",
                    Vector(Signup.Name.name -> "", Signup.Email.name -> "ada")
                  )
          html <- view.html
        yield assertTrue(
          html.contains("Enter your name."),
          html.contains("Enter an email address such as ada@example.com.")
        )
      }
    },
    test("thanks the visitor and clears the form after a valid submission") {
      ZIO.scoped {
        for
          view <- ConnectedRender.join(SignupLiveView())
          _    <- view.submitForm(
                    "#signup-form",
                    Vector(Signup.Name.name -> "Ada", Signup.Email.name -> "ada@example.com")
                  )
          status <- view.text("[role=status]")
          html   <- view.html
          name    = RenderedHtml.parse(html).selectOne("input[type=text]").map(_.value)
        yield assertTrue(status == "Thanks, Ada. Check ada@example.com.", name == Right(""))
      }
    }
  )

submitForm selects the form by CSS selector and sends its submit event with the given fields, as the browser would. The field names come from the form definition, such as Signup.Name.name, so a renamed field cannot leave the test behind. html returns the latest rendered HTML, and RenderedHtml queries it by CSS selector.

Test a page that uses a service

The thermostat takes its Thermostat service as a constructor argument, so a test can pass its own implementation. Create app/test/src/quickstart/ThermostatLiveViewSpec.scala:

Source
scala
package quickstart

import zio.*
import zio.test.*

import scalive.testing.*

object ThermostatLiveViewSpec extends ZIOSpecDefault:
  private def fixedSensor(current: Ref[Double]) = new Thermostat:
    def temperature: Task[Double] = current.get

  private val brokenSensor = new Thermostat:
    def temperature: Task[Double] = ZIO.fail(RuntimeException("sensor offline"))

  def spec = suite("ThermostatLiveView")(
    test("shows the reading after connecting and refreshes on request") {
      ZIO.scoped {
        for
          current <- Ref.make(20.0)
          view    <- ConnectedRender.join(ThermostatLiveView(fixedSensor(current)))
          _       <- view.awaitHtml("first reading", 5.seconds)(_.contains("20.0 °C"))
          _       <- current.set(21.5)
          _       <- view.clickButton("Refresh")
          _       <- view.awaitHtml("refreshed reading", 5.seconds)(_.contains("21.5 °C"))
        yield assertCompletes
      }
    },
    test("tells the visitor when the sensor does not answer") {
      ZIO.scoped {
        for
          view  <- ConnectedRender.join(ThermostatLiveView(brokenSensor))
          _     <- view.awaitHtml("failed reading", 5.seconds)(_.contains("role=\"alert\""))
          alert <- view.text("[role=alert]")
        yield assertTrue(alert == "The sensor did not answer.")
      }
    }
  ) @@ TestAspect.withLiveClock

The fake sensor reads a Ref, so the test decides each temperature. The reading arrives after the page connects, not as the reply to an action, so the test waits with awaitHtml until the rendered HTML satisfies a condition. If the condition never holds, the test fails after the timeout and reports the last HTML.

ZIO Test normally replaces the clock with a test clock that moves only when a test adjusts it, so a timeout would never expire. TestAspect.withLiveClock makes this suite use real time. It does not slow the tests down: the ten-second refresh timer never fires because each test finishes first.

Run mill app.test again. All five tests pass.

Go further

The testing guide covers the first HTTP response, routes with sessions and cookies, navigation, uploads, reconnects, and when to use a real browser instead. Next, How the pieces fit recaps the application you have built.