Unit Testing Strategies
I might be dating myself, but I've been around since unit testing generally meant using xUnit or Test Anything Protocol. Unit testing has come a long way, and it's worth the time to keep up with it because new features reduce the pain associated with writing unit tests. In my experience, it's this pain that disincentivizes developers to write good, high-coverage unit tests, even though we know we really should.
I've been using Vitest lately for testing my Typescript projects. Vitest is a test runner that piggybacks off of Vite while offering most of the functionality present in Jest. The examples I share will be based on Vitest's features and API, but most of the techniques are widely available. Check your preferred test runner's documentation to see how it exposes these features.
Table-Driven Tests
Table-driven tests have a long history, but my first exposure to the concept was via Go's built-in unit test functionality.
The basic idea is, if you're planning to test a function with a given set of inputs and an expected output, rather than repeat the tedious boilerplate for each test case, create a 2D array of values (the titular table) and iterate over it.
Here's how it looks in Vitest and Jest:
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('add(%i, %i) -> %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
Notice the printf
-like syntax allowing you to produce a name for each individual test that incorporates its unique values.
If your test runner doesn't have such functionality built-in, you can try to recreate it by hand.
const testCases = [
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
];
for (const [a, b, expected] of testCases) {
test(`add(${a}, ${b}) -> ${expected}`, () => {
expect(add(a, b)).toBe(expected);
});
}
Your mileage may vary. I seem to recall the past experience of some test runner choking on runtime-generated tests, but I just tested this approach in Vitest, Jest, and Mocha, and they all handled it properly.
If you do happen to find your test runner of choice doesn't handle this properly, you can fall back to a monolithic test case that runs all rows in your table.
const testCases = [
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
];
test(`add()`, () => {
for (const [a, b, expected] of testCases) {
expect(add(a, b)).toBe(expected);
}
});
The disadvantage to this approach is that a single row will cause the entire test case to fail, and the row at fault may not be readily apparent depending on your reporter configuration.
In any case, either through ignorance of the approach, or stubborn test runners, I've both seen and written test files with orders of magnitude more lines of code than what they're testing due to hundreds of lines of boilerplate copypasta. Unnecessarily verbose tests are hard to read and maintain. Use table-driven tests to cut the noise.
Because the table could potentially be generated at runtime using somewhat randomized values, I could also see table-driven tests used as a foundation for property testing. I've never seen it done in practice, but I bet a developer could get far using faker and some helper functions.
Snapshots
My first exposure to snapshot tests was via Jest. The idea was introduced within the framework of testing React-based UI components. At its core, a snapshot is a string containing a serialized representation of data. At test time, the value generated by the function under test is serialized and compared to the previously stored version. In the case of UI components, the snapshot is usually HTML or something very similar.
The hidden power of snapshots is that they needn't be restricted to UI tests. Jest's and Vitest's snapshot implementations work on practically all values. Try reaching for snapshots whenever you expect a large, deep object or array that's static.
Snapshots are overkill if you're testing for simpler objects or scalar values. They're inappropriate when the object you're testing against has values that may change per run, like IDs and timestamps.
Why should you use snapshots for non-UI tests instead of writing out your expected match values by hand? It's all about the tooling!
Your test runner will do the copypasta for you. But remember to manually review the snapshot to make sure it's correct.
Snapshots should be checked into your repository and should be code-reviewed just like everything else.
If the snapshot is small and you don't like having to flip between your test file and the snapshot file, use the in-line equivalent: expect(result).toMatchInlineSnapshot()
. Your test runner will generate a snapshot that lives in the test code instead of a separate file.
When the day comes that the snapshot test fails not because of a bug but because a change has led to an out-of-date snapshot, updating the snapshot is made easy by your test runner. Making the update usually involves running your test runner with a particular argument. Or your test runner may prompt you if you're running in an interactive mode.
In-Source Testing
In-source testing describes when your unit tests live in the same file as the code they test. Go and Rust users have enjoyed this option since the early days. This seems to be a rarity in the JavaScript world, however. In fact, Vitest is the only test runner I've come across that supports it! Here's why it matters.
While not a replacement for traditional documentation, types, and code comments, unit tests can serve as a form of supplemental documentation about how a programmer expects code to behave and how they expect it to be used. When the tests live literally right next to the code they test, it makes it easier to see the bigger picture.
More practically, placing tests within modules allows you to test private functions without having to jump through hoops.
JavaScript modules are file-based. Anything that is exported from the module can be thought of as that module's public API. Minimizing the breadth of a module's API is important. I've seen modules in the wild that export every function within them. This can lead to difficulties when it comes time to make changes, because if every line of code of a module is public, every behavior, even buggy behaviors, can be depended upon by some other module somewhere.
JavaScript tests have traditionally lived in separate files from the code they test. This means if you don't export a function, you can't test it directly. You can only test its behavior in the context of the publicly exported functions that call it.
This issue has caused friction since modules were introduced. I've seen several solutions that usually fall under the category of dependency injection, but I typically find them awkward and brittle.
That's not to say Vitest's solution is perfect. It does involve depending upon a relatively new feature of the module system. Getting it to work correctly in Typescript involves setting up your tsconfig.json
file just so. But it's the sanest solution to this problem I've encountered so far.
Here's the tsconfig.json
settings that got it working seamlessly for me:
{
// ...
"module": "es2022",
"moduleResolution": "node10",
"types": ["node", "vitest/importMeta"],
// ...
}
When your unit tests live within the module under test, everything in the module's scope is available to your unit tests: classes, functions, constants, imports, everything! Being able to test private functions has been a revelation and a relief. If your test coverage of your private functions is sufficient enough that you trust them, you typically no longer need to test those aspects of the functions that call them.
I hope you find these unit test features as helpful as I have. They've been invaluable additions to my programming tool belt.