Lessen the pain of mocking/stubbing by writing smaller JavaScript functions for easier unit testing
Let's talk about writing unit tests for JavaScript / Node applications.
Yeah, you know you "should be writing tests".
And you actually have been!
Writing tests for functions that return some value: you know how to do that. You feel comfortable writing tests for expected output values, like true
or some string
or toBeDefined
.
But it's those other functions - like one's that call an API, or hit a database, or even just do several different things (like complicated if/else branches) - those are the ones you're having trouble writing unit tests for.
Because in those scenarios, you usually have to write some kind of stub/mock or do some kind of dependency injection (DI) in order to truly unit test them - just testing the logic in the function and nothing else.
But if you could easily write tests for those functions (even if it meant you might have to do some mocking), I bet you'd be writing more tests and would feel more comfortable making changes to your codebase knowing you weren't going to accidentally break something.
Breaking apart your functions to make them easier to write unit tests
Even though you can't always escape stubbing/mocking things out in your tests, you can often break up your functions to make them easier to write true unit tests for without stubs/mocks.
Many, many times I see functions that are making a HTTP request or fetching data from a database, but they don't just do that...
they have several other things they're doing too...
like filtering the resulting data from the HTTP request, or formatting the data from that database call based on some flag passed in as an argument to the function, etc.
And often I'll see functions that are doing several more things on top of that!
So when it comes time to write tests for these functions, you have to stub/mock the database/API calls in addition to possibly having to stub other pieces of code _internal _to the function.
Talk about a massive pain.
But there's a way out.
And that way is to write more "unit" functions so you can more easily write unit tests.
It might seem simple, but if you want to more easily write unit tests, you have to write more unit functions. Functions that are broken down into the smallest pieces they can be (or reasonably small enough).
And then you have an integration function that takes those small unit functions and, well, integrates them. Combines them in a way that the application needs.
It's just like testing. You have your unit tests that test things at the smallest level, then you have your integration tests that test bigger things, things that are integrated and doing several different things.
The difference though this time, is that both of those kinds of tests will be much, much easier to write. And you may not need to stub/mock anything at all!
Example offender
Let's look at a function that would be painful to write tests for as it currently stands. This function is doing several small things, but each one of those small things does not currently exist as its own function.
async function getLoanInfo() {
const people = await callDb()
const financialAttributes = await callHttp()
return people.map(person => {
return {
person,
ageGroup: (person.age && person.age >= 50) ? '50 and up' : '49 and below',
meta: financialAttributes.find(attribute => person.zipCode === attribute.zipCode)
}
})
}
This function, in addition to fetching people records from the database and financial attributes from a third party API, also joins/formats that data based on some business logic.
The business logic here - the logic to join/format the records - is somewhat contrived but is typical of something you'd see in the real world.
If we wanted to test this, we'd have to stub the database call and the API call for each logic path we wanted to test. And what would logic would we mostly want to test here? That the joining/formatting happens correctly.
Instead of stubbing the external dependencies (database and API) just to test the joining logic, we could instead just pull that out into its own function, like so:
function joinAndFormat(people, financialAttributes) {
if (!people || !financialAttributes) return
return people.map(person => {
return {
person,
ageGroup: (person.age && person.age >= 50) ? '50 and up' : '49 and below',
meta: financialAttributes.find(attribute => person.zipCode === attribute.zipCode)
}
})
}
Smaller and easier to test!
And one of the benefits of writing smaller, unit functions is you see things you might have missed when it was part of one bigger function. For example, in this new function, I realized we should probably exit early if people
or financialAttributes
are not passed in!
Now, in our original getLoanInfo()
function, we just replace the join/format code with our new unit function:
async function getLoanInfo() {
const people = await callDb()
const financialAttributes = await callHttp()
return joinAndFormat(people, financialAttributes)
}
Smaller and easier to read!
Now, for the tests
Testing things at the unit level, this is what those unit tests would look like:
const deepEqualInAnyOrder = require('deep-equal-in-any-order')
const chai = require('chai')
const { joinAndFormat } = require('./index')
const { expect } = chai
chai.use(deepEqualInAnyOrder)
describe('joinAndFormat()', () => {
it('should return null if missing args', () => {
const people = [{person: 'tom'}]
const formatted1 = joinAndFormat(people)
expect(formatted1).to.be.null
const formatted2 = joinAndFormat()
expect(formatted2).to.be.null
})
it('should format correctly', () => {
const people = [
{person: 'Tom', age: 50, zipCode: 21345},
{person: 'Jack', age: 40, zipCode: 31680}
]
const financialAttributes = [
{zipCode: 21345, attributes: {spending: 'high', creditScoreAvg: 750}},
{zipCode: 31680, attributes: {spending: 'low', creditScoreAvg: 730}},
{zipCode: 45560, attributes: {spending: 'high', creditScoreAvg: 600}}
]
const formatted = joinAndFormat(people, financialAttributes)
expect(formatted).to.deep.equal([{
person: {person: 'Tom', age: 50, zipCode: 21345},
ageGroup: '50 and above',
financialInfo: {zipCode: 21345, attributes: {spending: 'high', creditScoreAvg: 750}}
},
{
person: {person: 'Jack', age: 40, zipCode: 31680},
ageGroup: '49 and below',
financialInfo: {zipCode: 31680, attributes: {spending: 'low', creditScoreAvg: 730}}
}])
})
it('should designate people as 50 and above', () => {
const people = [
{person: 'Tom', age: 50, zipCode: 21345}
]
const financialAttributes = [
{zipCode: 21345, attributes: {spending: 'high', creditScoreAvg: 750}}
]
const formatted = joinAndFormat(people, financialAttributes)
expect(formatted.pop().ageGroup).to.equal('50 and above')
})
it('should designate people as 49 and below', () => {
const people = [
{person: 'Tom', age: 49, zipCode: 21345}
]
const financialAttributes = [
{zipCode: 21345, attributes: {spending: 'high', creditScoreAvg: 750}}
]
const formatted = joinAndFormat(people, financialAttributes)
expect(formatted.pop().ageGroup).to.equal('49 and below')
})
})
Instead of having to stub/mock the database and API calls for people
and financialAttributes
, we just add some fake data in the structure they would be returned in. And we get to avoid involved test setup!
Using this method
Whenever you're having a hard time figuring out how to write a unit test for a function, check to see if you can pull out any of the code into a separate function(s) before you start stubbing/mocking things. Look for business logic code you can isolate from external calls. Look for logic in your if/else or switch bodies. And pull those out into their own functions.
Sometimes stubbing/mocking a function seems like the only way to test the logic for a function, but using this method you'll often be able to avoid having to do this for your unit tests!
This will make things much, much easier to write tests for. And I've found that tests only get written when they're easy to write...
If you found this post helpful, be sure to subscribe below to get all my future posts (and cheatsheets, example projects, etc.) delivered directly to your inbox without having to remember to check back here!
Subscribe for new posts!
No spam ever. Unsubscribe any time.