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
expect(document.querySelector('#skuList').style.display).toBe('block')
// other expectations
}
})
Code language: JavaScript (javascript)
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 await
ed 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
}
})
Code language: JavaScript (javascript)
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)
}
})
Code language: JavaScript (javascript)
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 beasync
. (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:
expect(yourObject)
.toEqual(expect.objectContaining({ id: 1, name: 'Quackadilly Blip' })
// .toEqual could also be `.toStrictEqual` if you need to check deep object equality.
Code language: JavaScript (javascript)
This works for arrays, objects, and strings. The expect
object has a bunch of neat stuff, actually.