Using spies as a way to test side-effects in Node
You're chugging along writing tests, but then you run into a scenario where you need to test a side-effect. Maybe that's a call to a database, or a call to an external HTTP endpoint, or just making sure a function gets called.
Maybe you're not used to setting up tests for these scenarios.
So you do some searching around and figure out you need to use something called "fakes".
But there are different kinds of fakes - spies, stubs, mocks... which do you use?
In this post we'll go over one of those types of fakes - spies - how to use them and when you might want to.
Example code
Let's look at some example code to test, from this queue example repo I wrote:
const consume = async (doWork, workQueue, processingQueue, exit = () => {}) => {
let workQueueHasItems = await checkQueueHasItems(workQueue)
while (workQueueHasItems) {
// first, check stale items in processing queue
await checkStales(workQueue, processingQueue, 120000) // 2 minute stale time
let workItem
try {
workItem = await getWork(workQueue, processingQueue)
} catch(e) {
console.error(`Error getting work item from ${processingQueue} queue: ${e}`)
}
try {
await doWork(workItem)
console.log(`completed work item: ${workItem}`)
await lrem(processingQueue, 1, workItem)
} catch(e) {
console.error(e)
}
workQueueHasItems = await checkQueueHasItems(workQueue)
}
exit()
}
This code is doing a few things:
- checking a queue has items
- checking for stale items
- pulling items from the queue
- ...and then processing them
But what we really want to test is the processing - that something is happening to the item after we pull it from the queue (i.e. - that doWork
function)
That's our "side effect" that happens as a result of consume()
being called.
What are our options to test that? We could define doWork
as a function that stores the item in a database. When we call consume()
, then for the test we could check that the item is in the database. But that's kind of a lot of work.
And while we care that the item is processed, we don't really care how it was processed.
A simpler test might be - as our doWork
function - logging the item from the queue to the console, using console.log()
, but then how do we check the console output?
That way is simpler - no database storing and retrieving that we need to do - but also is tricky to test.
However, we can test this a completely different way. And that's where spies come in.
Spies
The gist of spies is that they allow you to watch a function and track what arguments were passed to it, if it was called, how many times it was called, etc.
Hence the name "spy". You're spying on the function to see how it gets called.
You can spy on specific functions, for example:
sinon.spy(jQuery, 'ajax')
But you can also spy on anonymous functions. The use case for this is usually testing how a function handles a callback function passed to it, since that callback will be anonymous. Which is what we're going to leverage to test our side effect later on in this post.
Spies vs. stubs
Spies are distinct from stubs, another type of testing fake at your disposal.
The general use cases are:
- spy: you don't want to control side-effects, but want to test that they happened
- stub: you want to control side-effects (like faking an error, for example)
And when it comes time to test assertions, when you use spies in your test usually what you'll assert on is if something happened, not what happened.
Using spies with Sinon
Setting up a spy with Sinon is pretty easy. Just create one using sinon.spy()
.
Then, you can check things like:
const spy = sinon.spy()
spy.called
spy.notCalled // note: I generally prefer to use .called for this and check that it's false. just seems easier to read IMO
spy.calledOnce
spy.calledBefore // before another spy
spy.calledAfter // after another spy
spy.calledWith(arg1, arg2, ...)
spy.calledWithExactly(arg1, arg2, ...)
You can check out the full Sinon spy API here.
Test code
Now that we know what spies are, when you might use them, and what the Sinon implementation of the spy API looks like, we can probably figure out how to write the test.
Here's the test to make sure we've actually processed the items pulled off the queue (aka "consumed" them):
it('should process items from the queue', async () => {
// seed queue
await pushToQueue(WORK_QUEUE, JSON.stringify({
itemNum: 1,
isbn: 'default',
timestamp: Date.now()
}))
const doWork = sinon.spy() // anonymous function version of a spy
await consume(doWork, WORK_QUEUE, PROCESSING_QUEUE)
expect(doWork.called).to.be.true
})
Again, we only care that the item was processed in some way. We don't care what happened to the item - if it was stored in a database, if it was logged to the console, etc.
Is knowing what happened meaningful to our test in this scenario? No. Only that the item was processed.
Which is why we only need to check that doWork
was called, as demonstrated by the test assertion:
expect(doWork.called).to.be.true
We can also test that the spy wasn't called, in the case that there are no items in the work queue:
it('should do nothing if no items in work queue', async () => {
const doWork = sinon.spy() // anonymous function version of a spy
await consume(doWork, WORK_QUEUE)
expect(doWork.called).to.be.false
})
And that's it!
For this code under test, I chose not to check for presence of arguments. We could I guess, but that would really just be the workItem
, and that signature shouldn't change.
I also chose not to check the order in which it was called.
That is part of the API (spy.firstCall
, spy.calledBefore
, etc), but I generally don't like using those methods.
It can couple your test to your function implementation details too much sometimes. What happens if we change the order of two functions we're spying on?
That may be a legitimate change that doesn't break the output of the function, but would cause the test to fail.
Which leads me to my next point...
A word of caution on testing implementation details
You might be wondering if we should also set up spies for some of the other side-effect functions that are called within the consume()
function.
In my opinion, doing that would start to couple the tests to the implementation details too much. For example, what if we needed to rewrite it in the future to not check for stales (checkStales()
)?
Not that we necessarily would do that, but just as an example. The test with a spy on checkStales()
would now fail, even though we didn't break anything.
Now imagine there are 10 functions that we are spying on, and you can see that any future changes to consume()
have the potential to cause a lot of reworking of the tests. Which we don't want.
We don't want tests that are so brittle they fail - causing false negatives - every time we make changes.
Now, spying on doWork()
is OK, because that's core to consume()
- we can be confident that part of the API is not going to change.
If doWork()
was removed, then we wouldn't really be consuming anything. We'd be reading messages, yes, but not doing anything with them.
So in determining what to spy on, there is some work involved in understanding your functions / your API, and knowing what is central to it and unchanging. It takes some practice but eventually you develop the skill.
Wrapping up
Next time you have a function under test, and you need to check for side-effects happening as part of that function, use spies.
When you want to test that a call happened - it's core to the function under test - but don't care what happened, use spies.
That's the heuristic I use when determining how to test such a scenario.
Writing tests takes some time to get good at. And in my experience, it's been difficult to find strong tutorials showing you not only how to test, but what to test. In order to try to help bridge that gap, I've written a few other posts about exactly that:
- Know what to test using these recipes: Node service that calls a database
- Real world testing recipes: Node service that calls an external API
- Real world testing: Using business and technical requirements to know what to test
And if you want more posts about testing in Node, architecture, patterns, and other things that may be holding you back, sign up for the newsletter below! I write a new post every week or two and will send them to you immediately after they are published.
Sign up for the newsletter!
No spam ever. Unsubscribe any time.