Possibly the greatest value in having tests for your code is...
Corey Cleary
Posted on October 2, 2019
Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets and other freebies.
As developers, we constantly hear that we should be writing tests...
"All good developers know how to write tests, and they write them!"
But why?
We're told this will help prove our code is correct...
Or that it will prove we've met requirements...
Or that it will allow us to make changes without worrying if we broke something...
Or that it serves as a form of documentation...
And while all of those are true (for the most part at least- your code might not be truly bug-free unless you use something like formal methods/verification), I think possibly the greatest value in having tests for your code is in having documentation of inputs and outputs for your functions.
And then what I'd argue is definitely the best value in reading tests is in using those documented inputs and outputs to figure out how the rest of the application fits together. Because those inputs and outputs are the glue that piece the rest of the application together.
Truly grasping this value of tests will encourage you, as a developer, to not see tests as something pointless and skip writing them, but to start to enjoy it once you experience how much easier they make your life (and your teammates' lives). Most importantly, it won't feel like a chore anymore but just part of the natural developer workflow, like writing code itself.
Inputs and Outputs
So what exactly is meant by inputs and outputs? Let's look at an example from Node's Express framework:
If you've ever worked with Express, you've almost definitely used the res.send()
function. If you haven't, basically what it does is returns a response/data from your REST API.
Imagine you were working on fixing a bug in Express' code and you had to figure out how res.send()
works under the hood. First you might go to the function definition and start to look through the code. The first few lines of code look somewhat understandable:
res.send = function send(body) {
var chunk = body;
var encoding;
var req = this.req;
var type;
// settings
var app = this.app;
// allow status / body
if (arguments.length === 2) {
// res.send(body, status) backwards compat
if (typeof arguments[0] !== 'number' && typeof arguments[1] === 'number') {
deprecate('res.send(body, status): Use res.status(status).send(body) instead');
this.statusCode = arguments[1];
} else {
deprecate('res.send(status, body): Use res.status(status).send(body) instead');
this.statusCode = arguments[0];
chunk = arguments[1];
}
}
Basically some setup stuff going on, some backwards compatibility stuff going on, some prep for setting the encoding, etc. Even if it's not immediately apparent what the code is doing or why it's doing it, it's still just Node code and, aside from the deprecate
method (which is fairly self-explanatory), the code is "self-contained". I.e. - no other function calls, yet, that we need to understand.
Let's go a little further down the definition:
// write strings in utf-8
if (typeof chunk === 'string') {
encoding = 'utf8';
type = this.get('Content-Type');
// reflect this in content-type
if (typeof type === 'string') {
this.set('Content-Type', setCharset(type, 'utf-8'));
}
}
Ok, we're checking the request type to see what type it is, then we're getting the HTTP Content-Type
from the HTTP request, then... what's that setCharset
function doing?
From the surrounding context of the code we can kinda figure out what it's doing mostly by just it's name, but how do we know what it returns? And how do we really understand the various inputs (arguments) we can call it with?
This is where reading the tests for that function comes in handy in understanding its inputs and outputs. setCharset
is a utility function, so we can search in our IDE or editor for the utils.js
tests:
describe('utils.setCharset(type, charset)', function () {
it('should do anything without type', function () {
assert.strictEqual(utils.setCharset(), undefined);
});
it('should return type if not given charset', function () {
assert.strictEqual(utils.setCharset('text/html'), 'text/html');
});
it('should keep charset if not given charset', function () {
assert.strictEqual(utils.setCharset('text/html; charset=utf-8'), 'text/html; charset=utf-8');
});
it('should set charset', function () {
assert.strictEqual(utils.setCharset('text/html', 'utf-8'), 'text/html; charset=utf-8');
});
it('should override charset', function () {
assert.strictEqual(utils.setCharset('text/html; charset=iso-8859-1', 'utf-8'), 'text/html; charset=utf-8');
});
});
As you can tell, these tests provide several different values for the inputs/arguments, from which we can also understand the output. I.e. -
- if we don't provide any inputs, we will get 'undefined' as the output
- passing in an already formatted charset like `text/html; charset=utf-8` will just return the same string
- passing in two separate values such as `text/html` and `utf-8` will combine them into `text/html; charset=utf-8`
- and if we pass in a charset value in the first argument, as well as one for the second argument, the second argument takes preference, like `text/html; charset=iso-8859-1` and `utf-8`
In essence, we now understand how to call this function, and what we will get back from the function. What the function does...
Now, what if we wanted to look at setCharset
under the hood?
function setCharset(type, charset) {
if (!type || !charset) {
return type;
}
// parse type
var parsed = contentType.parse(type);
// set charset
parsed.parameters.charset = charset;
// format type
return contentType.format(parsed);
};
It too, executes other functions! These, from the content-type module.
We can follow the same process to get an understanding of these functions, and thus, a better understanding of setCharset
.
I'll just show a few of the contentType.parse
and contentType.format
tests here:
it('should parse basic type', function () {
var type = contentType.parse('text/html')
assert.strictEqual(type.type, 'text/html')
})
it('should parse with suffix', function () {
var type = contentType.parse('image/svg+xml')
assert.strictEqual(type.type, 'image/svg+xml')
})
it('should format basic type', function () {
var str = contentType.format({ type: 'text/html' })
assert.strictEqual(str, 'text/html')
})
it('should format type with suffix', function () {
var str = contentType.format({ type: 'image/svg+xml' })
assert.strictEqual(str, 'image/svg+xml')
})
The glue
Now going back to the res.send()
function, we can now better understand why the setCharset
function is there:
if (typeof type === 'string') {
this.set('Content-Type', setCharset(type, 'utf-8'));
}
We need to format the Content-Type
header for our HTTP response, and setCharset
formats that into a valid string for the header.
This is why I say inputs/outputs are the "glue" of your code. You might not understand what one function or part of a function does, but by understanding the values passed to it and what you get back/what actions that function takes, you can start to piece together your understanding of the rest of the application code.
The function chain call here is: res.send
-> utils.setCharset
-> content-type.parse/format
.
At a basic level, code is functions which operate on data (inputs). After all, if you never pass in anything to your functions... what would your application really do? Not much. Not very usable...
And tests are the documentation of those inputs/outputs that allow you to unlock a deeper understanding of the code. Sure, some code you can read and understand without relying on reading tests. In fact, most good code should be readable in this way.
But even if you can immediately understand what the code does, you may not understand how it will operate with different values passed to it, like in the should override charset
test from above.
Versus JSDoc
If you're familiar with JSDOC, you'll know it will give us some documentation of inputs/outputs above the function definition. Usually something like this:
/**
*
* @param {Object} config - Configuration used for blah blah blah.
*
* @returns {Promise} A promise resolved after the config has been used to setup blah blah blah.
*/
function configureApp(config) { /* function body */ }
But JSDoc, although helpful, really just gives the type of the inputs (@param
) and output (@returns
), with a small description. It's not the most complete documentation. Can you imagine using JSDoc to add all the input combinations from the setCharset
tests above? It would be a massive pain.
With tests, we get a much clearer picture.
And if you found this post helpful, here's that link again to subscribe to my newsletter!
Posted on October 2, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.