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:
-
A HTML snapshot is much easier to review than a dump of the internal Lustre virtual DOM.
-
The test is written in pure Gleam and doesn’t require any external dependencies or a browser to run: you can run these tests on either the Erlang or JavaScript target, and you can use whatever test runner you want.
-
Because we test the top-level
view
and then query for parts we care about, we test things hollistically. If we refactor our view code we won’t have to update the test as long as the output is the same, and if it changes we can see what changed and accept or reject it.
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:
-
Pass your app’s existing
init
,update
, andview
functions to create a simulated application. -
Simulate events on an element by passing a query, the name of the event to simulate, and a custom payload for your app’s actual event handler to decode.
-
Interrupt the simulation to assert something about the current state of the view.
-
Simulate messages from effects and other external systems.
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.