Earlier this year, I got rid of my virtual private server and migrated the content to a static hosting system. In the process, I broke an old web project that had a submission form.
The submission form used to be handled by a small Go application that accepted form input, validated it, persisted it to a SQLite database, and then wrote new JSON data for the public website. Obviously, that broke when I got rid of the server where the application was hosted.
I decided to rewrite the form handler as an AWS Lambda function that ingested JSON and updated some S3 content. I hadn’t ever written a Lambda function before, and I decided I should try to deploy it with Terraform, which I also haven’t touched, so there was a slightly painful learning curve. It took an evening to get it all working again.
You can write Lambda functions in Ruby, Golang, Java, and Python, among others, but I decided to write mine in Node, since it seems like the quintessential “serverless” language.
I decided I had to have unit tests for my lambda function. But as far as I know, Node doesn’t come with a built-in test framework.* People generally use third party test frameworks for Node apps.
And the problem is, I didn’t really feel like installing a new dependency just to write some unit tests for one single lambda function. Dependencies are debts. Better to avoid them if you don’t really need them.
So I wrote my own tiny test framework.
A DIY test framework
It is structured as follows.
It does this at the top of the file:
import {testables} from './index.mjs'; // A map of functions to be tested
const testSuite = []; // A list of tests - a "suite" if you will
const test = (functionUnderTest, implementation) => {
testSuite.push(
{
name: functionUnderTest, // the name of the function you want to test
f: testables[functionUnderTest],
implementation: implementation // test implementation
}
);
};
It then provides you two simple assertions.
const assertEq = (value, expect, eq = (a, b) => { return a == b }) => {
if (!eq(value, expect)) {
console.error(` ❌ ${inspect(value)} != ${inspect(expect)}`);
process.exit(1);
} else {
console.info(` ✅ ${value} == ${expect}`);
}
}
const assertJsonEq = (value, expect) => {
return assertEq(
value,
expect,
(a, b) => { return JSON.stringify(a) == JSON.stringify(b) }
);
}
These will exit(1)
and print an error whenever the test fails. I didn’t need anything more than this.
Finally, at the bottom of the file, there’s a test runner.
const main = async () => {
for (let t of testSuite) {
console.info(`Testing ${t.name}...`);
await t.implementation(t.f);
}
console.info('\n🦄 Test suite successful 🦄');
};
main();
If you invoke this with node test.mjs
, it runs all the tests in order, exiting with an error as soon as any test fails.
An example test declaration looks like this:
test('beep', (f) => {
assertEq(f(1), "beep");
assertEq(f(2), "boop");
});
This just asserts that f(1)
equals "beep"
, and f(2)
equals "boop"
.
The test implementation accepts one argument f
, which will be set to the function called beep
from the original module. We retrieve the beep()
function from the testables
module export that we imported at the top. (This all presumes you are using ES6 modules or whatever they’re called now.)
Discussion
I was pretty happy with this amount of test tooling for the tiny personal project I was using. For my purpose, it did an excellent job - in particular, it got the lambda function ready for deployment with relatively high confidence that it would work. And it did deploy with minimal issues (it had a few IAM issues, and one function signature issue that the unit tests missed).
I’m not saying this test framework is good enough for a large project. It always fails fast, which isn’t necessarily what you want. It doesn’t provide multiple output formats, flexible test naming, complex matchers, test randomization, timing data, mocks and stubs, memoized test data setup, or much else.
However, by sacrificing a lot of what you usually get from a test framework, it also gains a few things:
- It has a very clean, readable DSL (in my opinion).
- It has zero dependencies.
- It is very quick to run.
But most importantly, it reminds me that you don’t always need a framework. The concepts here are so straightforward that for a simple use case, you can write your own assertions and your own runner, and it basically just works.
Note 1: Node does come with an assertion framework in the standard library, even if not a full test framework. I would probably use their assertions next time, instead of the DIY assertions.
Note 2: In terms of dependencies, I did end up adding one AWS mocking library, aws-sdk-client-mock, to be able to test functions that used the AWS SDK. But this isn’t essential to the basic DIY test framework here.