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.

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.

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" in bsconfig.json
  • add "type": "module" to package.json
  • run npx rescript clean --with-deps and then npm 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 the Zora module bringing all of its types into the current namespace. This allows you to run tests and assertions without having to say, e.g Zora.ok.
  • zoraBlock is a function that starts a suite tests. The suite gets a name and a function with a t object that can have assertions applied to it. The Block indicates that this is a blocking test. There is also a zora function that returns a Promise instead. Zora runs functions that return Promise 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 value true 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 the Promise.resolve function to indicate to Zora that you are returning a Promise. I felt calling it Zora.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!