Writing Your First Specification
In this tutorial we’ll specify and check an audio player web application using Quickstrom.
The tutorial assumes you’re running on a Unix-like operating system and that you have Docker installed. You may run this using other installation methods, but all commands in this document are using Docker.
Open up a terminal and create a new directory to work in:
$ mkdir my-first-spec
$ cd my-first-spec
Installing with Docker
In this tutorial you need a working installation of Docker. Head over to docker.com and set it up if you haven’t already.
Next, pull the QuickStrom image using Docker:
$ docker pull quickstrom/quickstrom:0.5.0
Downloading the Audio Player
The web application we’re going to test is already written. Download
it using curl
:
$ curl -L https://github.com/quickstrom/quickstrom/raw/main/docs/source/_static/audioplayer/audioplayer.html -o audioplayer.html
If you don’t have curl
installed, you can download it from this
URL
using your web browser. Make sure you’ve saved it our working
directory as audioplayer.html
.
$ ls
audioplayer.html
OK! We’re now ready to write our specification.
A Minimal Specification
We’ll begin by writing a specification that always makes the check
pass. Create a new file audioplayer.strom
and open it in your text
editor of choice:
$ touch audioplayer.strom
$ $EDITOR audioplayer.strom
Type in the following in the file and save it:
1import quickstrom;
2
3action ~playOrPause! = click!(`.play-pause`);
4
5let ~proposition = true;
6
7check proposition with * when loaded?;
A bunch of things are going on in this specification. Let’s break it down line by line:
Line 1: We import the Quickstrom module. This is where we find definitions for DOM queries, actions, and logic. We also import Maybe which we’ll need later on.
Line 3: Our actions specify what Quickstrom should try to do. In this case, we want it to click the play/pause button.
Line 5: In the
proposition
, we specify what it means for the system under test to be valid. For now, we’ll set it totrue
, meaning that we require only a single state, and that any such state is considered valid.Line 7: The
check
statement tells Quickstrom how to test our application. We ask it to check our definedproposition
, with all declared actions, once theloaded?
event has occured.
Running a Test
Let’s run some tests! Launch Quickstrom from within your
my-first-spec
directory:
$ docker run --shm-size=1g --rm \
-v $PWD:/my-first-spec \
quickstrom/quickstrom:0.5.0 \
quickstrom -I/my-first-spec check \
audioplayer \
/my-first-spec/audioplayer.html \
--browser=chrome
You should see output like the following:
The test passed.
Cool, we have it running! So far, though, we haven’t done much testing. Quickstrom doesn’t do more than the specification requires, and right now any initial state is good enough, so it doesn’t perform any actions. Let’s make our specification say something about the audio player’s intended behavior.
Refining the Proposition
Our system under test (audioplayer.html
) is very simple. There’s
a button for playing or pausing the audio player, and there’s a time
display.
Our specification will describe how the player should work. Informally, we state the requirements as follows:
Initially, the player should be
paused
When
paused
, and when the play/pause button is clicked, it should transition to theplaying
stateWhen in the
playing
state, the time display should reflect the progress with a ticking minutes and seconds displayWhen
playing
, and when the play/pause button is clicked, it should go to thepaused
state, and time should not changeIn the
paused
state, the button should say “Play”In the
playing
state, the button should say “Pause”
Let’s translate those requirements to a formal specification in Quickstrom.
Begin by defining two element helpers, extracting the text content of the play/pause button, and extracing and parsing the time display text. The time is represented as total number of seconds in our specification, making it easier to compare.
Place these just after the imports section in audioplayer.strom
:
let ~buttonText = `.play-pause`.textContent;
let ~timeInSeconds =
let [minutes, seconds] = split(":", `.time-display`.textContent);
parseInt(minutes) * 60 + parseInt(seconds);
Next, we’ll define the two states as booleans:
let ~playing = buttonText == "Pause";
let ~paused = buttonText == "Play";
We also need to declare the actions a bit more precisely. Change to existing action declaration to the following:
action ~pause! = click!(`.play-pause`) when playing;
action ~play! = click!(`.play-pause`) when paused;
Finally, we’ll change the proposition
. Remove true
and type in
the following code:
let ~proposition =
let ~play = ...;
let ~pause = ...;
let ~tick = ...;
paused && (always {20} (play || pause || tick));
Note
The ...
parts aren’t valid ecpressions, but we’ll replace them with valid ones in the next section.
The last line in our proposition can be read in English as:
Initially, the record player is paused. From that point, one can either play or pause, or the time can tick while playing, all indefinitely.
OK, onto adding the missing parts!
The Missing State Transitions
We have a bunch of ...
placeholders in our state transition
formulae. Let’s fill them in!
The definition play
describes a transition between
paused
and playing
:
let ~play =
paused
&& nextT playing
&& unchanged(timeInSeconds);
OK, so what’s going on here? We specify that the current state is
paused
, and that the next state is playing
. That’s how we
encode state transitions. We also say that the time shouldn’t change.
Note
We need to use nextT
instead of next
here, because we don’t
want to force another state being read. If there is a next state
available, we say that it should be playing
, otherwise we
default to true. That’s what the T
in nextT
means.
The pause
transition should look similar:
let ~pause =
playing
&& nextT paused
&& unchanged(timeInSeconds);
Finally, we have the tick
. When we’re in the playing
state,
the time changes on a tick
. The time should be monotonically
increasing, so we compare the current and the next time:
let ~tick =
playing
&& nextT playing
&& (let old = timeInSeconds; nextT (old < timeInSeconds));
That’s it! Your proposition should now look something like this:
let ~proposition =
let ~play =
paused
&& nextT playing
&& unchanged(timeInSeconds);
let ~pause =
playing
&& nextT paused
&& unchanged(timeInSeconds);
let ~tick =
playing
&& nextT playing
&& (let old = timeInSeconds; nextT (old < timeInSeconds));
paused && (always {20} (play || pause || tick));
Let’s run some more tests.
Catching a Bug
Run Quickstrom again, now that we’ve fleshed out the specification:
$ docker run --shm-size=1g --rm \
-v $PWD:/my-first-spec \
quickstrom/quickstrom:0.5.0 \
quickstrom -I/my-first-spec check \
audioplayer \
/my-first-spec/audioplayer.html \
--browser=chrome
You’ll see a bunch of output, involving shrinking tests and more. It should end with something like the following:
Transition #1
Actions and events:
- click('33a4c299-a382-44c2-870e-b48335f9c23a')
State difference:
`.play-pause`
╒══════════════════════════════════════╤══════════════════════════════════════╕
│ 33a4c299-a382-44c2-870e-b48335f9c23a │ 33a4c299-a382-44c2-870e-b48335f9c23a │
│ enabled: true │ enabled: true │
│ interactable: true │ interactable: true │
│ textContent: "Play" │ textContent: "Play" │
│ visible: true │ visible: true │
╘══════════════════════════════════════╧══════════════════════════════════════╛
`.time-display`
╒══════════════════════════════════════╤══════════════════════════════════════╕
│ bd797365-5239-4a9b-9976-942288bd227a │ bd797365-5239-4a9b-9976-942288bd227a │
│ textContent: "00:00" │ textContent: "00:00" │
╘══════════════════════════════════════╧══════════════════════════════════════╛
Look, we’ve found our first bug using Quickstrom! It seems clicking the play/pause doesn’t do anything. It should change the label to “Pause” to indicate it’s in the playing state.
The problem is the button text. Open up audioplayer.html
, and change the
following function called playPauseLabel
:
function playPauseLabel(state) {
let label;
switch (state) {
case "playing":
label = "Pause";
case "paused":
label = "Play";
}
return label;
}
It should be:
function playPauseLabel(state) {
switch (state) {
case "playing":
return "Pause";
case "paused":
return "Play";
}
}
Are we done? Is the audio player correct? Not quite.
Transitions Based on Time
The audio player transitions between states mainly as a result of
user action, but not only. A tick
transition (going from
playing
to playing
with an increased time) is triggered
by time.
In addition to our play!
and pause!
actions, we’ll add a wait!
action. It does nothing for at most two seconds, until either something
happens (a tick?
event) or a timeout:
action ~wait! = noop! timeout 2000;
But this means we have to allow nothing to change. This is often
called a stutter state. Let’s do that, but only in combination with
a noop!
action. Add one more state transition to the proposition:
let ~proposition =
...
let ~wait =
nextT (contains(noop!, happened))
==> unchanged([timeInSeconds, playing]);
paused && (always {20} (
play
|| pause
|| tick
|| wait
));
Run another check by executing the same command as before:
$ docker run --shm-size=1g --rm \
-v $PWD:/my-first-spec \
quickstrom/quickstrom:0.5.0 \
quickstrom -I/my-first-spec check \
audioplayer \
/my-first-spec/audioplayer.html \
--browser=chrome
You should see output such as the following:
parseInt could not parse: "NaN"
ParseInt @ /my-first-spec/audioplayer.strom:15:2
<fun> @ /nix/store/xradz0aav0ijajw91x1vqfqsprgipfhx-specstrom/share/ulib/control.strom:30:21
Whoops, look at that! It crashes because the time display shows “NaN”,
which is definitely not intended behavior. Open up
audioplayer.html
, and change the following lines near the end of
the file:
case "pause":
return await inPaused();
They should be:
case "pause":
return await inPaused(time); // <-- this is where we must pass in time
Rerun the check using the same quickstrom
command as before. It passes!
Summary
Congratulations! You’ve completed the tutorial, created your first specification, and found multiple bugs.
Have we found all bugs? Possibly not. This is the thing with testing. We can’t know if we’ve found all problems. However, with Quickstrom you can increase your confidence in the correctness of your web app, especially if you continously run tests on it.
This tutorial is intentionally fast-paced and low on theory. Now that you’ve got your hands dirty, it’s a good time to check out The Specification Language to learn more about the operators in Quickstrom.