Quickly Testing Rescript With Zora
Introduction
This is part of an ongoing series about the Rescript programming language. I’ve been taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem. The toy app has evolved into a fairly complete progressive web app at this point, but it’s missing a key feature: unit tests!
I took a break from writing these articles a while ago to instead write rescript-zora; bindings to the zora test framework. Now I want to show how to use them.
Patreon
This series takes a lot of time and dedication to write and maintain. The main thing that has kept me invested in this writing this series is support from my Patrons.
Other ways to show support include sharing the articles on social media, commenting on them here, or a quick thank you on the Rescript forum.
Other articles in series
With over a dozen articles and counting, I’ve created a table of contents listing all the articles in this series in reading order.
Let’s begin
This article will add tests to the rescript server in the
rescript-express-recipes repo.
You can git checkout
the
frontend-hookup
branch if you want to start from the same place I am in this article.
If you want to follow along with the changes in this article, I try to make a separate commit for each section. You can find them in the zora branch.
Note that there are a few extra commits at the beginning of that branch where I converted the project to Rescript 9.1.1.
Why Zora? (Some links)
Zora is not a well-known Javascript test
framework, and I only chose it because it came up when I searched for fastest test framework
. Rescript has taught me that instantaneous response times are a
huge boon for developer velocity. Much more important than robust features
that I rarely use. Speed and the ability to run tests were the only feature I
wanted in a test system.
Zora is so bare bones, that it doesn’t even come with the ability to run tests. However, Zora’s author has also created pta, a test runner for NodeJS projects. It’s also lightning fast.
And of course, Zora does not have Rescript bindings. Normally, I just define these on the fly as needed. However, it felt like Zora deserved it’s own Rescript package, so I wrote rescript-zora.
Installing the dependencies
Obviously all this third-party stuff needs to be installed:
npm install --save-dev zora pta onchange @dusty-phillips/rescript-zora
Note that I added a dependency on onchange as well, a lightweight file watcher.
Also update your bsconfig.json
to add a dependency on rescript-zora
in
addition to the existing bs-express
dependency:
"bs-dependencies": [
"bs-express",
"@dusty-phillips/rescript-zora"
]
While you have bsconfig.json
open, add a new tests
directory to the
sources. This will entail putting the existing src
dict in an array and
adding a new tests
dict. It’ll look like this:
"sources": [
{
"dir": "src",
"subdirs": true
},
{ "dir": "tests", "subdirs": true, "type": "dev" }
],
The "type": "dev"
bit tells Rescript that while these files should be compiled,
they should not be made available to other Rescript modules. So, for example,
you wouldn’t be able to import tests from the Index.res
.
Don’t forget to mkdir tests
!
With all these deps installed, you can now update your package.json
tests
script as follows:
"test": "onchange --initial '{tests,src}/*.js' -- pta 'tests/*.test.bs.js'",
and npm test
will now start a lightning-fast test runner that automatically updates
when you save a file.
Zora seems to be much happier when working with es6 modules instead of commonjs, so you should also do the following:
- change
"module": "commonjs"
to"module": "es6"
inbsconfig.json
- add
"type": "module"
topackage.json
- run
npx rescript clean --with-deps
and thennpm rescript
to regenerate everything in es6 module format.
Write your first test
Create a new file in the tests
directory named TestStore.test.res
(the .test
matches the file glob in the npm test
command we wrote):
open Zora
zoraBlock("testing_works", t => {
t->ok(true, "I told you it works")
})
If the test runner running, it should pick this file up and run the tests immediately.
I like fast.
Change the true
to false
to see what a failure looks like in zora.
Let’s break that little test down line by line:
open Zora
opens theZora
module bringing all of its types into the current namespace. This allows you to run tests and assertions without having to say, e.gZora.ok
.zoraBlock
is a function that starts a suite tests. The suite gets a name and a function with at
object that can have assertions applied to it. TheBlock
indicates that this is a blocking test. There is also azora
function that returns aPromise
instead. Zora runs functions that returnPromise
in parallel for speed. But at the top level of a module like this, you probably want to use the blocking version. You can nest non-blocking tests inside of it.t->ok(true, "I told you it works")
is a test assertion. You are asserting that the valuetrue
is… well,true
. In real life, you’d be asserting this on a variable or comparison.
Parallel tests are faster
I want to add a second test, but I want the tests to run in parallel because of that blazing need for speed. Here’s how to define two parallel tests:
open Zora
zoraBlock("testing_works", t => {
t->test("testing works", t => {
t->ok(true, "I told you it works")
done()
})
t->test("testing still works", t => {
t->ok(true, "I told you it works")
done()
})
})
There are a couple subtle changes in this example:
- We added two calls to
test
, in each of which an assertion happens. - The tests end with a call to
done()
. This is actually just a type alias to thePromise.resolve
function to indicate to Zora that you are returning aPromise
. I felt calling itZora.done()
was a little more elegant.
Let’s talk about the naming scheme. I strongly believe in an “async first”
world, such that all APIs are written async by default, though they may have
blocking wrappers. I think that rather than async
and await
, modern
programming languages should make their default function names be the async
ones, and then maybe add sync
and block
keywords if you are going to do
something blocking.
My naming choices in rescript-zora reflect that. Async tests are defined using
test
, while the blocking version is called block
.
I also like that the word block
has two meanings here. I think the most
common usecase for a blocking suite is to contain multiple non-blocking tests
inside of it. In other words, a Zora.block
could be a “block” of non-blocking
tests.
Let’s test some real code
One of the wonderful things about reducer-style code is how easy they are to
test. Given a state, apply some changes, return a state. It’s always the same
thing. So let’s start our unit testing explorations with the reducers in the
Store.res
file.
This is why the initial tests are in a file named TestStore.test.res
. Replace
the two tests you just defined with a new block of tests for the addRecipe
reducer:
open Belt
open Zora
zoraBlock("Test recipes Store", t => {
t->test("Adds a recipe to empty state", t => {
let state = Store.initialState
let action = Store.AddRecipe({
id: "abc",
title: "Bread",
ingredients: "Flour, Water",
instructions: "Mix and Bake",
})
let newState = Store.reducer(state, action)
t->equal(newState.recipes->Map.String.size, 1, "Should be one recipe in the map")
t->ok(newState.recipes->Map.String.has("abc"), "The one recipe should have id 'abc'")
let recipe = newState.recipes->Map.String.getExn("abc")
t->equal(recipe.title, "Bread", "The titles should match")
t->equal(state.tags->Map.String.size, 0, "Should not add any tags")
done()
})
})
Honestly, the only new thing here is the use of the Zora.equal
assertion,
which does what you expect. This test sets up an initial state and action,
invokes the reducer on the action, and then makes several assertions about the
end state to ensure it meets expectations.
The interesting thing about this test case is that, while it only tests the
“happy path” through the code, I can’t find a way to test any negative cases.
There’s no point in testing what happens, for example, if you pass undefined
as the action payload, because Rescript will catch that case at compile time.
Hopefully some of the more interesting actions around tags will give us
something worth testing. But first, let’s test the even simpler setRecipe
action. Add a new t->test
call below the existing one:
t->test("setRecipe does not add two recipes", t => {
let state = Store.initialState
let action = Store.SetRecipe({
Store.id: "abc",
title: "Bread",
ingredients: "Flour, Water",
instructions: "Mix and Bake",
tags: [],
updatedAt: 500.0,
deleted: false,
})
let state = Store.reducer(state, action)
t->equal(state.recipes->Map.String.size, 1, "Should be one recipe in the map")
let state = Store.reducer(state, action)
t->equal(state.recipes->Map.String.size, 1, "Should still be one recipe in the map")
done()
})
One thing you may find interesting here is how I reassigned the state
variable multiple times. This does not mean the state is mutable. I’m just
reusing the variable name to point to different values at different times.
Testing AddTag
The AddTag
action is the most likely to be error-prone due to its complexity.
There are several paths through it, and two pieces of state have to be updated.
I’m actually pretty confident it works because the compiler would have caught
most of the impossible situations, but let’s see if testing proves me wrong!
Let’s start by testing the simplest no-op case: you can’t tag a recipe that doesn’t exist. Start a new nested test block inside the default test:
t->test("AddTag action", t => {
t->test("noop when recipe does not exist", t => {
let state = Store.initialState
let action = Store.AddTag({
recipeId: "doesn't exist",
tag: "add me",
})
let state = Store.reducer(state, action)
t->equal(state.recipes->Map.String.size, 0, "Should not have added a recipe")
t->equal(state.tags->Map.String.size, 0, "Should not have added a tag")
done()
})
done()
})
I’ve created a AddTag action
test that I can use to group several related
tests together (Right now there’s only one). Inside that test, I added a new
test for the no-op case. It just ensures that adding a tag to a recipe that
doesn’t exist does not change the initial state.
The tricky bit is that this inner test is supposed to run in parallel with any
other tests I add inside the AddTag action
block. Further, the outer AddTag action
needs to run in parallel with the other tests in the outer default
block. Therefore we need two done()
methods to signal to Rescript that this
is a promise.
The next test is quite a bit more complicated, both because the initial setup requires a bit of work and because there are plenty of assertions to make after the action has completed. Let’s start with the setup. We need a state that has one recipe and no tags:
t->test("creates tag when it does not exist", t => {
let state: Store.state = {
recipes: Map.String.empty->Map.String.set(
"abc",
{
Store.id: "abc",
title: "Bread",
ingredients: "Flour, Water",
instructions: "Mix and Bake",
tags: [],
updatedAt: 500.0,
deleted: false,
},
),
tags: Map.String.empty,
}
done()
})
The state is defined as having the type Store.state
, and then is created in
Rescript’s standard “record” format. This shouldn’t be too new to you, but the
way that I constructed a recipes map by chaining empty into Map.String.set
inline in the record definition is kind of neat.
Now let’s add some assertions (before the done()
call):
let action = Store.AddTag({recipeId: "abc", tag: "Carbs"})
let state = Store.reducer(state, action)
t->equal(state.recipes->Map.String.size, 1, "Should still have one recipe")
t->equal(state.tags->Map.String.size, 1, "Should have one tag")
let breadOption = state.recipes->Map.String.get("abc")
t->optionSome(breadOption, (t, bread) => {
t->equal(bread.tags->Array.size, 1, "Bread should have one tag")
t->equal(bread.tags->Array.getUnsafe(0), "Carbs", "Bread tag should be carbs")
})
let tagsOption = state.tags->Map.String.get("Carbs")
t->optionSome(tagsOption, (t, tag) => {
t->equal(tag.tag, "Carbs", "tag should have correct name")
t->equal(tag.recipes->Array.size, 1, "Tag should have one recipe")
})
I don’t think this needs too much explanation either, as the messages in each
assertion show what is going on. The one new bit is the optionSome
function, which both asserts that the option is a Some
value and calls a callback to allow you to define assertions on that value.
I’ll add one more test. It’s very similar to the previous one, but starts with one tag so we are testing the append rather than new tag case:
t->test("appends tag when it does exist", t => {
let state: Store.state = {
recipes: Map.String.empty->Map.String.set(
"abc",
{
Store.id: "abc",
title: "Bread",
ingredients: "Flour, Water",
instructions: "Mix and Bake",
tags: ["Baking"],
updatedAt: 500.0,
deleted: false,
},
),
tags: Map.String.empty->Map.String.set(
"baking",
{Store.tag: "baking", recipes: ["abc"], updatedAt: 500.0, deleted: false},
),
}
let action = Store.AddTag({recipeId: "abc", tag: "Carbs"})
let state = Store.reducer(state, action)
t->equal(state.recipes->Map.String.size, 1, "Should still have one recipe")
t->equal(state.tags->Map.String.size, 2, "Should have two tags")
let breadOption = state.recipes->Map.String.get("abc")
t->optionSome(breadOption, (t, bread) => {
t->equal(bread.tags->Array.size, 2, "Bread should have two tag")
t->equal(bread.tags->Array.getUnsafe(0), "Baking", "First bread tag should be Baking")
t->equal(bread.tags->Array.getUnsafe(1), "Carbs", "Second bread tag should be carbs")
})
let tagsOption = state.tags->Map.String.get("Carbs")
t->optionSome(tagsOption, (t, tag) => {
t->equal(tag.tag, "Carbs", "tag should have correct name")
t->equal(tag.recipes->Array.size, 1, "Tag should have one recipe")
})
done()
})
This test opens us up to the standard philisophical debate about unit testing. It is almost identical to the previous test, and, indeed, testing this scenario could have been done more easily by adding a couple assertions at the end of the previous test instead of creating a new one. I’m not going to get into that debate, but will say that I would probably have written it the other way in production. In this case, I wanted to show the parallel execution for pedagogical reasons.
There is one more case that needs to be tested, but I’m going to leave it as an
exercise for the reader: A test that starts with a tag in the tags array, and
adds a second recipe with that tag. I’ll also leave the SetTaggedRecipes
action
as an exercise.
Bonus: Coverage
Code coverage with zora is incredibly easy. They recommend using the c8
engine:
npm install --save-dev c8
Depending on whether you like to run coverage on file save (warning, it slows
you tests down from “instant” to “holy crap that’s fast”, you can use it with
onchange
by adding the following to your scripts
array:
"test:coverage": "onchange --initial '{tests,src}/*.js' -- c8 --all pta 'tests/*.test.bs.js'",
"test:htmlcoverage": "onchange --initial '{tests,src}/*.js' -- c8 ---reporter html -all pta 'tests/*.test.bs.js'"
Now you can run npm run test:coverage
and watch your coverage numbers grow with
every test you write.
Personally, I prefer to run coverage periodically, and use test watching only when I’m actively developing, so I have my set like this:
"test:coverage": "c8 --all pta 'tests/*.test.bs.js'",
"test:htmlcoverage": "c8 ---reporter html -all pta 'tests/*.test.bs.js'"
You can, of course, support both options by using command names like
test:coverage:watch
or whatever works for you.
Conclusion
This is getting a little lengthy, so I’m going to stop it here for this week. I’ve got some vacation time coming up, so I’m hoping to flesh out my backlog a bit so I can keep these articles coming. (If you appreciate me spending vacation hours on this, don’t forget about my Patreon!)
In the next article, I’m going to see if I can hook Zora up to test the express
endpoints. I am anticipating some issues with parallel tests there, because of
the useReducer
global state. Which just goes to show that global state is a
bad idea!