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:
//| 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 appTestModule.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:
scalive-quick-start/
└── app/
├── src/
│ └── quickstart/
└── test/
└── src/
└── quickstart/
├── CounterLiveViewSpec.scala
├── SignupLiveViewSpec.scala
└── ThermostatLiveViewSpec.scalaWrite a first test
Create app/test/src/quickstart/CounterLiveViewSpec.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")
}
}
)View source (documentation/fixtures/learn/test/src/quickstart/CounterLiveViewSpec.scala:2-24)
The spec extends ZIOSpecDefault, groups tests with suite, and asserts with
assertTrue. ConnectedRender.joindef join[Msg, Model](liveView: LiveView[Msg, Model]): zio.package.RIO[zio.Scope, testing.ConnectedView[Msg]]Renders a page, then connects to it as a browser would, and returns the connected view.
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:
mill app.testMill 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:
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(""))
}
}
)View source (documentation/fixtures/learn/test/src/quickstart/SignupLiveViewSpec.scala:2-40)
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
RenderedHtmlobject RenderedHtmlConstructors for RenderedHtml snapshots. 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:
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.withLiveClockView source (documentation/fixtures/learn/test/src/quickstart/ThermostatLiveViewSpec.scala:2-40)
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.