Testing async code in Jest

it is apparently not over until the jest test is `done()`

I’m in the process of learning Jest and doing my first TDD in Javascript.

It’s hard! Jest is tough, even though it’s supposed to make JS testing a bit easier.

Testing async code (literally, code that uses the async keyword), in particular, is tricky, especially when you have non-obvious calls to async functions.

For instance! I’m using Robot to manage state in the app I’m building. You can call asynchronous functions from within the state machine. Specifically, I’m using Robot’s invoke functionality to make API calls, since invoke takes a function that returns a promise, and if you call a function defined with async without using await, you get a Promise back.

Await expectations

So in my case, I have a (synchronous) function that returns a state machine that, in certain states, calls async functions via Promises.

In testing the state machine’s functionality, I wrote a test that looked like:

test('it shows SKU list when it receives SKUs', () => {
  buildStateMachine(initialstate, handler) // returns a state machine which invokes async functions -- not at all obvious (even though I wrote the code!)
  // Also accepts a handler which runs on every state transition

  function handler(service) { // The handler takes the state machine "service"
    // Do work here e.g. update DOM
    if (service.machine.current !== 'someStateWeAreTesting') return // Make sure we're in the right state
    // other expectations

This test would pass even though it should have failed, if for instance I hadn’t yet written the code that actually updates the display property of #skuList.

More confusingly, Jest would still log exceptions to the console. It knew exceptions were thrown, but it still passed the test.

It turns out, even though I’m not explicitly calling a function that should await, or even explicitly then-ing a Promise, I should have still defined the test function as async and awaited the expect calls.

test('it shows SKU list when it receives SKUs', async () => { // This is an async test! Even though it doesn't look like it!
  buildStateMachine(initialstate, handler)

  async function handler(service) { // Async the handler so we can `await` our `expect` calls
    if (service.machine.current !== 'someStateWeAreTesting') return
    await expect(document.querySelector('#skuList').style.display).toBe('block')
    // other expectations

With that change, Jest would fail the test when an exception was thrown, and the expect calls actually worked. Logging was weird, though, and the test still behaved in unpredictable ways. (At least unpredictable to me, a Jest neophyte.)

Callback heck

But there was one more tricky thing: the system under test uses a callback, which does nothing until it’s in a future state.

When the state machine transitions to another state, the handler function is called. But by the time the machine was in the state I was testing, Jest had finished executing the test. The callback would run, but Jest had already moved on, without running the expect calls in the callback. This makes sense, if you understand how the JS event loop works, BUT APPARENTLY I DO NOT.

Turns out, Jest has a convenient callback of its own, called done, which indicates when a test is…done.

test('it shows SKU list when it receives SKUs', async done => { // Pass in done
  buildStateMachine(initialstate, handler)

  async function handler(service) {
    if (service.machine.current !== 'someStateWeAreTesting') return
    await expect(document.querySelector('#skuList').style.display).toBe('block')
    done() // It ain't over till the done runs (or the built-in timer runs out)

Finally! This test finally passed and failed in predictable ways.

Two big Jest learnings:

  • If your system under test calls any async functions — even if they’re deeper in there and it’s not super obvious — your test function should also be async. (Though I could be wrong here! I don’t have a good intuition for Jest yet.)
  • If your system under test needs a callback to happen in order to get into the state that you’re actually testing, pass the done function to your test, and call it when you want the test to exit.

This took days of frustrating debugging to figure out since the errors were cryptic and unpredictable. Couldn’t have done it without this Pluralsight post or the Jest docs.

Love-hate relationship

Jest (maybe JS testing in general?) is hard and asynchronous code is hard. Debugging in particular leaves a lot to be desired, especially compared with e.g. Laravel’s PHPUnit setup. Also I’m not crazy about the mocking situation. JSDom will sometimes fail in weird, unexpected ways, e.g. by being weird about calls to window.location. It’s an amazing tool, but I find that roadblocks happen in what feel like random, frustrating ways.

BUT there are a couple of things I really like about it so far.

For instance, by some arcane magic, it knows what you’ve changed since your last commit, and knows tests are affected by your changes. If you run it with the --watch flag, it’ll automatically run only tests that are affected by code you change. No more running the whole test suite after hours of work only to find out you’ve broken everything — you’ll know immediately!

Also, it allows really flexible, composable expectations for even complicated data structures. So for instance, instead of JSON.stringify-ing your way to checking object equality (brittle! weird!), you can write a test like:

  .toEqual(expect.objectContaining({ id: 1, name: 'Quackadilly Blip' })
// .toEqual could also be `.toStrictEqual` if you need to check deep object equality.

This works for arrays, objects, and strings. The expect object has a bunch of neat stuff, actually.