Lustre v5.1.0 released!

Last month we released Lustre v5.0.0 with a focus on making the framework more attractive for production use: huge performance improvements, better integration with the Web as a platform, and a much better engineering culture around tests and benchmarks of the framework itself. This month we’re continuing that trend by making it easier to write robust tests for your view functions and applications.

Writing tests without a browser

Like any other kind of application, it’s often helpful to write tests for your frontend apps to make sure they work correctly! This often ends up being a lot of automated browser tests that can quickly grind your CI to a halt.

To help encourage a positive testing culture, Lustre now includes two new modules that allow you to write tests that don’t need a browser at all. These are lustre/dev/query and lustre/dev/simulate. Let’s take a quick look at each of them.

Using queries to write view snapshots

The lustre/dev/query module allows you to take an Element and extract a portion of the view to make it easier to snapshot test using birdie. Snapshot tests save the rendered HTML of a view to a file and then every time the test runs, it compares the current output to the saved output. If they differ, the test fails and you get a chance to review the output and accept or reject the change.

Let’s say we have the view function for a page that we want to test. Snapshotting the entire page’s HTML would make it difficult to see what changed, so instead let’s write a test that just snapshots the hero:

import app/page/index
import birdie
import lustre/dev/query
import lustre/element

pub fn hero_test() {
  let selector = query.tag("section") |> query.and(query.class("hero"))
  let assert Ok(element) = query.find(in: index.view(), matching: selector)

  element
  |> element.to_readable_string
  |> birdie.snap("The hero section of the landing page")
}

The selector we’ve written reads plainly and does exacltly what you’d expect: it finds the first <section> element with the class "hero" in the view. Because this is a test, we want it to crash if the element can’t be found so we use Gleam’s let assert syntax to make sure the test fails if the element can’t be found. Then we convert the element to a string and get birdie to take a snapshot of it.

When we run the test, birdie automatically generates the following file for us:

---
version: 1.2.6
title: The hero section of the landing page
file: ./test/app_test.gleam
test_name: hero_test
---
<section class="hero content">
  <h2>
    The Universal Framework
  </h2>
  <p>
    Static HTML, SPAs, Web Components, and interactive Server Components.
  </p>
  <button class="cta">
    Get Started
  </button>
</section>

Nice! There’s a lot of good reasons to write tests like this:

Now imagine we change the copy in our hero section. When the we re-run the test birdie has our back and presents us with a diff!

── mismatched snapshots ────────────────────────────────────────────────────────

  title: The hero section of the landing page
  file: ./test/app_test.gleam
  test_name: hero_test

  - old snapshot
  + new snapshot

─────────┬──────────────────────────────────────────────────────────────────────
       1 │   <section class="hero content">
       2 │     <h2>
       3 │       The Universal Framework
       4 │     </h2>
       5 │     <p>
  6      -       Static HTML, Single-page applications, Web Components, and interactive Server Components.
       6 +       Static HTML, SPAs, Web Components, and interactive Server Components.
       7 │     </p>
       8 │     <button class="cta">
       9 │       Get Started
      10 │     </button>
      11 │   </section>
─────────┴──────────────────────────────────────────────────────────────────────

  a accept  accept the new snapshot
  r reject  reject the new snapshot
  s skip    skip the snapshot for now

These kinds of tests are obviously no good for testing how things look, but they are an excellent and quick way to confirm that the structure of your view is correct.

Simulating your application

Sometimes we might know that a particular view produces the correct HTML, but we want to know if a sequence of actions in our app will get us to that view. You could create a sequence of messages, fold over your update function, and then take a snapshot of your view using that final Model, but what if you want to make sure your event listeners are working properly, or you want to make sure the right sequence of views is produced?

The lustre/dev/simulate module allows you to take the core building blocks of a Lustre application - your init, update, and view functions - and simulate a running app.

Here it is in action:

import app
import app/user.{User}
import lustre/dev/simulate
import lustre/dev/query
import lustre/element

pub fn user_login_test() {
  let form = query.element(query.test_id("login-form"))
  let app =
    simulate.application(app.init, app.update, app.view)
    |> simulate.start(Nil)
    |> simulate.submit(on: query.element(form), fields: [
      #("email", json.string("lucy@gleam.run")),
      #("password", json.string("strawberry")),
    ])

  let assert Ok(_) =
    query.find(
      in: simulate.view(app),
      matching: query.element(matching: query.text("Loading...")),
    )
    as "Should show loading state while logging in"

  let response = Ok(User(name: "Lucy", email: "lucy@gleam.run", role: "mascot"))

  app
  |> simulate.message(ApiReturnedSession(response))
  |> simulate.view
  |> element.to_readable_string
  |> birdie.snap("The dashboard after logging in")
}

Woah, that’s quite a lot to unpack. In this small snippet we’ve shown you can:

Importantly, the simulation doesn’t simulate effects. This keeps the test pure and allows you to focus on the sequence of messages and interactions you expect without needing to mock out the entire world. If you want to test your real effects, that’s when you should pull in a browser and run a full end-to-end test!

Packages that do handle effects are encouraged to provide their own simulation functions that wrap simulate.message. You can find examples of this in both modem and rsvp.

We hope these two apis will encourage folks to write more tests for their Lustre applications without slowing them down. Keep your eyes peeled for next release where we’ll be making even bigger strides in this space 👀.

Better support for uncontrolled inputs

In Lustre there are two ways to work with stateful DOM elements like forms and inputs: “controlled” inputs that are syncronised with your application’s Model and “uncontrolled” inputs that let the DOM manage the state of the element instead.

Uncontrolled inputs can often be nicer to work with in cases where you don’t need to know about every single input, just the final value, but they had one important shortcoming: because your application doesn’t controll the input anymore, you can’t set an initial value!

This release, we’ve added the default_value attribute that allows you to prefill an uncontrolled input with a value. This only affects the input while the user hasn’t touched it, which means the default value is slightly different to a placeholder. Instead, you might use this to prefill a form with a user’s email address already associated with their account but still allow them to change it if they want to:

import app/user.{type User}
import formal.{type Form}
import gleam/function
import lustre/element.{type Element}
import lustre/element/html

pub fn update_email_form(
  for user: User,
  on_submit to_msg: fn(Result(String, Form)) -> msg
) -> Element(msg) {
  let handle_submit = fn(values) {
    form.decoding(function.identity)
    |> form.with_values(values)
    |> form.field("email", form.email |> form.and(form.must_not_be_empty))
    |> form.finish
    |> to_msg
  }

  html.form([event.on_submit(handle_submit)], [
    html.input([
      attribute.type_("email"),
      attribute.name("email"),
      attribute.default_value(user.email),
    ]),
    html.button([attribute.type_("submit")], [
      text("Update email"),
    ]),
  ])
}

And the rest

We’ve also added more attributes to lustre/attribute for working with HTML tables, made it possible to use both event.debounce and event.throttle on the same event, and squashed a bunch of bugs!


Lustre is still largely maintained by me – Hayleigh – with the support of a small number of contributors. To my existing sponsors on GitHub, thank you! Your support has fueled me with both motivation and caffeine to keep the project growing 💕.

If you’re interested in supporting Lustre, one of the best things you can do is build something with it and tell everyone about it!

If you or your company are using Lustre in production, please consider supporting the project financially over on GitHub sponsors.

Search Document