Making test stub setup effortless using Sinon
When you're writing tests, it's often the general setup like creating fake data and setting up stubs/mocks/fakes that can hold you back. Sometimes to the point where you end up skipping writing the tests altogether...
This setup can feel tedious and creates more work to do on top of having to write the rest of the test, come up with the assertion, and make sure you're covering reasonable test scenarios.
But by using the testing library Sinon - and having a few real-world/practical examples - test setup can become effortless.
I'm going to go over one of those examples in this post to help explain how to write stubs/fakes so you can quickly write your tests and get back to writing application code.
The unit under test
The unit under test here - the getBinarySliceFromFile()
function - returns a slice of a binary file based on positional arguments.
export const getBinarySliceFromFile = (start, offset, fileName) => {
const fileBinary = fs.readFileSync(`./binaries/${fileName}`)
return fileBinary.subarray(start, start + offset)
}
readFileSync()
returns the file in binary/Buffer format.start
here is the starting point to slice the file, so you could slice from any arbitrary pointoffset
is sort of like the end point to slice, but rather than an explicit end, it's used along withstart
to define the end
The issue is that in this function it's reading from the file system via the Node fs
module. We want to test the output of this function, so we need to be able to control what fileBinary
is. And we can't hardcode the directory path + filename.
To do this, we can stub out / fake the fs
module. This will allow us to set specifically what to return from the fs.readFileSync()
function, which is what we want.
The test code
describe('getBinarySliceFromFile()', () => {
afterEach(() => {
sinon.restore()
})
it('should return a slice of the binary starting from an index point', () => {
const index = 1
const offset = 1
// fake a binary
const fakedFileBinary = Buffer.alloc(2)
// output of the below is <Buffer 81 0b>
fakedFileBinary.writeUInt16LE(2945)
// create the Sinon fake, so that it returns the fake binary above
sinon.replace(fs, 'readFileSync', sinon.fake.returns(fakedFileBinary))
const slice = getBinarySliceFromFile(index, offset)
expect(slice.length).to.equal(1)
// notice that the below is the last block in <Buffer 81 0b>, which proves the "slicing" works
expect(slice).to.deep.equal(Buffer.from([0x0b]))
})
})
The fake setup in the code above happens here:
// fake a binary
const fakedFileBinary = Buffer.alloc(2)
// output of the below is <Buffer 81 0b>
fakedFileBinary.writeUInt16LE(2945)
// create the Sinon fake, so that it returns the fake binary above
const readFileSyncFake = sinon.fake.returns(fakedFileBinary)
sinon.replace(fs, 'readFileSync', readFileSyncFake)
On the first two lines, it's nothing Sinon-specific but just assigning a Buffer (which is binary data) to a variable and then writing data to that Buffer.
The next part is where Sinon comes into play:
const readFileSyncFake = sinon.fake.returns(fakedFileBinary)
sinon.replace(fs, 'readFileSync', readFileSyncFake)
This is using the Sinon fakes API, which are similar to stubs and likely what you are more familiar with hearing. According to the Sinon docs, "Fakes are alternatives to the Stubs and Spies, and they can fully replace all such use cases."
sinon.fake.returns
lets us setup the fake using the simulation of the file binary. And the .replace()
function lets us specify the fs
method to "overwrite" - in this case readFileSync
- and instead return the fake.
That code is pretty simple to setup but very powerful. Now we don't have to modify the function definition for getBinarySliceFromFile()
and can instead call it as usual. When it gets called, when it comes time to hit the fs
code Sinon will have swapped that out with the fake. And you can get an output from getBinarySliceFromFile()
to then make assertions on. A pretty effortless way of doing it.
Test cleanup
You may have noticed there is an afterEach()
block defined at the beginning of the test suite:
afterEach(() => {
sinon.restore()
})
Doing this will reset Sinon and the defined fakes after each test, which is a good practice for test "cleanliness" so you're starting from a clean slate with each test and there aren't leftover fakes from previous tests that never got reset.
Note that fakes are available in Sinon with version 5 and up. If you are using a version below that I would recommend upgrading, but if you can't do that you can use the stub API that Sinon provides, and is very similar.
This vs. passing in as an argument
One thing we could have done and avoided Sinon and fakes entirely would have been to pass the file binary in as an argument to the function, like so getBinarySliceFromFile(start, offset, fileBinary)
(note the last arg was changed from the file name to the file binary). If we did that it would let us pass in a simulated binary (just like how we set it up using a Buffer above) in the tests rather than using Sinon.
This can be a valid way, but I don't think it's great to do that here. Any code that calls getBinarySliceFromFile()
will now have to call fs.readFileSync()
and pass the result of that to getBinarySliceFromFile()
, which starts to break encapsulation. Whereas if we keep fs.readFileSync()
in the function body, consumers of getBinarySliceFromFile()
only need to pass in a file name, and that function is responsible for fetching the file binary. This is better encapsulation in my opinion.
Summary
The next time writing tests is holding you back because either you don't know how to setup fakes/stubs, or the setup is just too tedious, use Sinon and the example test code above to help guide you and make it very easy to get those tests written!
And if you're feeling like you don't know where to even begin with real testing examples and patterns, the below posts will help you with that:
- Using spies as a way to test side-effects in Node
- 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
If you want more posts on testing, architecture, and Node design patterns, signup below to receive all my new posts directly to your inbox:
Subscribe for more architecture, Node and testing content!
No spam ever. Unsubscribe any time.