In order to illustrate how to unit test with private functions correctly, I’ll be using the following code that attempts to delete a temporary markdown file.
NOTE: The messaging
module is a simple module that writes standard messages out to the console. I’ve kept all user-facing messages in a single file in order to increase maintainability. The messages aren’t important for this demo and they will be stubbed in our unit tests anyway.
var fs = require('fs'); var path = require('path'); var messaging = require('./messaging'); module.exports = { /** * Deletes the `temppdf.md` file. * * Deletes the temporary PDF markdown file. * * @returns {boolean} Returns true if the file is was successfully deleted (or non-existent). Returns false if the file cannot be deleted (e.g. file lock, etc.). */ deleteTempPdf: async function () { let tempPdf = path.resolve('temppdf.md'); let stat = await checkFileAccess(tempPdf); if (stat == 0) { messaging.printTempPdfMessage(0); return true; } else if (stat == 1) { messaging.printTempPdfMessage(1); return false; } else if (stat == 2) { // File is writable (e.g. no lock), attempt to delete fs.unlinkSync(tempPdf); // Check file status again stat = await checkFileAccess(tempPdf); if (stat == 0) { messaging.printTempPdfMessage(2); return true; } else { messaging.printTempPdfMessage(3); return false; } } else { messaging.printTempPdfMessage(4); return false; } } } /** * Checks file access. * * Checks a given file for access on the filesystem. * * @param {string} file Path of the file. * * @return {number} 0 if file doesn't exist; 1 if file is readonly; 2 if file is writable */ function checkFileAccess(file) { return new Promise((resolve) => { fs.access(file, fs.constants.F_OK | fs.constants.W_OK, (err) => { if (err) { if (err.code === 'ENOENT') { resolve(0); } else { resolve(1); } } else { resolve(2); } }); }); }
What you see here are two methods – one public and one private – deleteTempPdf
and checkFileAccess
. The checkFileAccess
uses the fs
library to check file access conditions.
NOTE: fs.exists
has been deprecated and, therefore, I’m using fs.access
.
As you can see form the JSDoc description, `checkFileAccess` returns one of three possible values: ‘0’ if the file doesn’t exist; ‘1’ if the file is readonly (e.g. possible file lock, or current permissions don’t allow write access); or, ‘2’ if the file does exist and is writable.
Based on these return values, deleteTempPdf
will attempt to perform the necessary operation(s). If the file doesn’t exist or does exist, but is deleted successfully, deleteTempPdf
will return true
for a successful deletion. Otherwise, deleteTempPdf
will return false
, meaning the file cannot be deleted.
Looking at the if-then
structure of deleteTempPdf
the logic is as follows:
- If the file doesn’t exist, return
true
- Else, if the file is readonly, return
false
- Else, if the file does exist, but is writable:
- Attempt to delete the file, then check the file again
- If the file doesn’t exist (deleted successfully), return
true
- Otherwise (the file wasn’t deleted successfully for whatever reason), return
false
- Else (unknown issue), return
false
With these five branches (note that points 3 and 3.1 are simply steps in the process and we only need to test 3.2 and 3.3), we can identify the five unit tests we need to write:
it('should return true for "temppdf.md" not existing')
it('should return false for "temppdf.md" being readonly')
it('should return true for "temppdf.md" existing, being writable and being deleted successfully')
it('should return false for "temppdf.md" existing, being writable, but not deleted successfully')
it('should return false for unknown error when checking access of "temppdf.md"')
The initial barebones version of our unit test spec file should look like the following:
describe('tempFile', () => { describe('deleteTempPdf', () => { it('should return true for "temppdf.md" not existing', () => { }) it('should return false for "temppdf.md" being readonly', () => { }) it('should return true for "temppdf.md" existing, being writable and being deleted successfully', () => { }) it('should return false for "temppdf.md" existing, being writable, but not deleted successfully', () => { }) it('should return false for unknown error when checking access of "temppdf.md"', () => { }) }) })
Upon successfully writing all five unit tests, we’ll achieve 100% code coverage.
On the next page, we’ll start filling in these unit tests.
This was an extremely helpful walkthrough – exactly what I needed. Also a very good real world example of something programmers will encounter. The code I’ve been assigned to test is full of situations like this.
Thanks!
Great blog Joshua! Should we also unset the rewired module after each test?
“`
const unset = tempFile.__set__({
‘checkFileAccess’: checkFileAccess
});
…testing…
unset();
“`
Thank you.
Great question! You could unset the module if you’d like, however, it isn’t absolutely necessary for two reasons: 1) before each test, you are reloading the temp file and 2) the temp file that you are using is a cached version maintained by rewire. In other words, you’re not overriding (or stubbing) the actual imported implementation, only a copy of it. In reality, the only time you may wish to unset it is if you set the method for a specific test case, but need to unset it for other cases within the same spec.
Hopefully, this makes sense.
And yet this does not explain how to test private methods. The example shows how to test different return values from checkFileAccess but it does not in fact check that checkFileAccess would return these values in different scenarios. The tests only test the deleteTempPdf function under different condition. But how can you trust the checkFileAccess function if you didn’t have a single test that checks its behavior and that it in fact returns the correct values by doing the fs.access() correctly? You didn’t.
Actually it does. You see, that’s exactly the point of explicitly setting the
err
object. When you set theerr
object, you’re stubbing the return value offs.access()
. We’re not testingfs.access()
. Instead, we’re testingcheckFileAccess()
based on the various values (e.g., error codes) thatfs.access()
could possibly return.