Writing Table-Driven Tests

SkySpecs Engineering
5 min readMay 19, 2021

--

Brad Olson, Senior Platforms Engineer, SkySpecs

Table-driven testing has been around a long time (see the ‘Prior Art’ section toward the bottom), but I am going to talk here about the tools that Jest provides to make table-driven testing even easier.

Table driven tests let you get a lot of coverage in a little code — not just code coverage, but error-space coverage. It doesn’t fit every situation, but when you want to cover a collection of scenarios, table-driven tests help you focus on the inputs and outputs of your code.

Another advantage of table driven tests is that the tables themselves may help you discuss requirements with stakeholders. If you can list example inputs and expected results in a spreadsheet or CSV and can use a library to load that table into tests, you can discuss detailed requirements without looking at code. Some people love code, but you’ll often find yourself working with people who instantly understand a spreadsheet but go into a coma if you show them JSON.

Basic Use

With table-driven tests, you simply pass various data records iteratively into the same testing function. Jest provides describe.each and it.each to make this surprisingly easy.

Here is some actual SkySpecs code from how we parse file paths in our uploader. Admittedly, it’s a pretty inane test, but it helped us define the base parser conditions and has stuck around as a sanity test. Notice how the test cases are defined in an array of arrays which is passed into test.each.

const [be, bi] = ["BLADE_EXTERNAL", "BLADE_INTERNAL"]// The Test Cases
const uploadTypes = [
['BLADE_INTERNAL', bi],
['BLADE_EXTERNAL', be],
['Blade Internal (Legacy)', be],
[bi, bi],
[be, be],
[bi.toLowerCase(), bi],
[be.toLowerCase(), be],
[bi.toUpperCase(), bi],
[be.toUpperCase(), be],
];
describe('parse upload types', () => {
// Here the test cases are injected, one at a time, into the test.
test.each(uploadTypes)('%#: "%s"', (input, expected) =>
expect(parseInspectionType(input)).toEqual(expected)
);
});

The call to test.each looks a little strange—a function with two sets of params. It's just a function returning a function that accepts a normal test signature. To break the pieces down further:

describe('parse upload types', () => {  
const testIterator = test.each(uploadTypes);
const descriptionTemplate = '%#: "%s"';
const theTest = (input, expected) =>
expect(parseInspectionType(input)).toEqual(expected);
testIterator(descriptionTemplate, theTest);
});

Notice that the description template lets you dynamically set the description string using these positional substitutions. Also notice that each case in the array of test cases is also an array whose values are input to the test function as ordinal parameters.

Each test case has to be an array, as far as I’m aware. You can use it how you like. For example, if you want named attributes, this pattern works well:

const testCases = [
["description", {a: 1, b:2, result: 509}],
["the special 103 case", {a: 103, b:2729, result: -5}],
];
// then use it like this:
describe("table-driven test with descriptions", () => {
test.each(testCases)("%#. %s", (desc, {a, b, result}) =>
expect(wackyFunctionNumberNine(a,b).toEqual(result);
});

Advanced Table Creation

You can also create tables in a textual format in Jest that you may find more readable. Here’s another, richer example from SkySpecs’ path parser tests (sorry the code wrapping doesn’t look good on Medium):

describe.each`
path | bladePosition | distance | bladeChamber | description
${'a/between_web_1/10_5.jpg'} | ${'A'} | ${10.5} | ${'BETWEEN_WEB_1'} | ${'between web 1'}
${'a/between_web_2/20_5.jpg'} | ${'A'} | ${20.5} | ${'BETWEEN_WEB_2'} | ${'between web 2'}
${'a/suction_side/10_5.jpg'} | ${'A'} | ${10.5} | ${undefined} | ${'between web 1'}
${'a/pressure_side/20_5.jpg'} | ${'A'} | ${20.5} | ${undefined} | ${'between web 2'}
${'a/root/30.jpg'} | ${'A'} | ${30} | ${'ROOT'} | ${'root'}
${'a/leading_edge/30_5.jpg'} | ${'A'} | ${30.5} | ${'LEADING_EDGE'} | ${'leading edge'}
${'c/trailing_edge/7_5.jpg'} | ${'C'} | ${7.5} | ${'TRAILING_EDGE'} | ${'trailing edge'}
${'a/trailing_edge/7_5.jpg'} | ${'A'} | ${7.5} | ${'TRAILING_EDGE'} | ${'trailing edge'}
${'k/leading_edge/30.jpg'} | ${undefined} | ${30} | ${'LEADING_EDGE'} | ${'Incomplete: invalid position'}
${'a/somewhere/30.jpg'} | ${'A'} | ${30} | ${undefined} | ${'Incomplete: invalidchamber'}
${'b/leading_edge/asdf.jpg'} | ${'B'} | ${undefined} | ${'LEADING_EDGE'} | ${'Incomplete: invalid distance'}
${'a/somewhere/asdf.jpg'} | ${'A'} | ${undefined} | ${undefined} | ${'Incomplete: only position valid'}
${'k/leading_edge/asdf.jpg'} | ${undefined} | ${undefined} | ${'LEADING_EDGE'} | ${'Incomplete: only chamber valid'}
${'k/somewhere/30.jpg'} | ${undefined} | ${30} | ${undefined} | ${'Incomplete:only distance valid'}
`(
'Blade Internal parsing $path, $description',
({ path, bladePosition, distance, bladeChamber, description }) => {
test('parses', () =>
expect(parseBladeInternalPath(path)).toMatchObject({
bladePosition,
distance,
bladeChamber,
}));
test('parses with blade-type logic', () =>
expect(parseInspectionUploadPath('BLADE INTERNAL', path)).toMatchObject({
bladePosition,
distance,
bladeChamber,
}));
}
);

Notice several things, each with some interesting implications:

Template Literal. The tests cases are in a textual table (which [prettier](<https://prettier.io/>) will automatically format). This is handy, but it also means you can't simply pass a string to describe.each or test.each. If you're feeling adventurous, read the MDN docs on "tagged template literals", they're a trip.

The First Row is Named Headers. Using this syntax, Jest turns each line into an object who’s keys are the column names and whose values are the cell contents. These values become available to the description string as $columnName. They just magically get interpolated.

Every Cell is an Interpolation Value. The first time I saw this syntax I thought, “What the heck! Why all the dollar signs‽” We’ve used interpolation syntax before, ${someVariable}, but probably not with constants. You don't have to use constants, but you do have to use interpolation values because of how template literals work. Moreover, they give you a way to embed typed values into a string table.

Describe Also Accepts Tables. When you use describe.each, the cases are passed to the whole test suite, allowing you to use one table for multiple tests.

Tips & Tricks

Concurrency. Try test.concurrent.each if you want to async your tests.

Exception Testing. If you need to test for exception-causing situations, one easy method is something like this:

const testCases = [
[1, 2, undefined, true],
[3, 4, 777, false].
];
describe("Exception testing", () => {
test.each("%#", (x, y, expected, shouldExcept) => {
if (shouldExcept)
expect(()=>surpriseMe(x,y).toThrow();
else
expect(surpriseMe(x,y)).toEqual(expected);
});
});

Prior Art

Table driven testing isn’t new. Cucumber/Gherkin has scenarioOutline. Go and Rust have table-driven testing built into their standard libs. The Robot Framework and Gauge lean towards tables, and there have been several commercial test frameworks that present their test cases in a spreadsheet.

Conclusion

Personally, I’ve found that trying to write table-driven tests keeps my test code shorter and my library code cleaner. If I’ve written a test scenario that’s more than 10 lines of code and it’s not a table driven test, I ask myself if I could’ve saved time and gained clarity by reaching for a table. Sometimes the answer is no, but it’s a worthy ask.

Most test frameworks, with just a little thought, can adapt to table driven tests. If you try techniques like this and find them useful, please share your experiences!

--

--