This short post demonstrates a helpful technique that can significantly simplify writing unit tests in JavaScript and TypeScript. I'm using TypeScript here with Jest as the testing framework. The approach presented works equally well for Mocha and should also work for other similar test runners.
So, what is it about?
When writing unit tests, we often need to pass a variety of input data to our component under test (a function or class - often called system under test, or shortly SUT) and check how it behaves.
We quickly have tests that look very similar because we usually copy and paste them and make minor adjustments. Data-driven tests can remedy this situation and help to keep the test code clear and easier to change. A simplified example will illustrate this. Let's assume we have a Type Predicate isPerson
.
Tests for that might look like this:
type Person = {
firstName: string;
lastName: string;
};
function isPerson(input: any): input is Person {
return (
input &&
typeof input.firstName === "string" &&
input.firstName.length > 0 &&
typeof input.lastName === "string" &&
input.lastName.length > 0
);
}
Tests for that might look like this:
it("accepts a valid person", () => {
const input = { firstName: "John", lastName: "Doe" };
expect(isPerson(input)).toBe(true);
});
it("accepts a valid person, ignoring unknown properties", () => {
const input = { firstName: "John", lastName: "Doe", foo: 42 };
expect(isPerson(input)).toBe(true);
});
it("rejects null", () => {
expect(isPerson(null)).toBe(false);
});
it("rejects undefined", () => {
expect(isPerson(undefined)).toBe(false);
});
it("rejects an empty object", () => {
expect(isPerson({})).toBe(false);
});
it("rejects an array", () => {
expect(isPerson([])).toBe(false);
});
it("rejects true", () => {
expect(isPerson(true)).toBe(false);
});
it("rejects false", () => {
expect(isPerson(false)).toBe(false);
});
it("rejects a number", () => {
expect(isPerson(42)).toBe(false);
});
it("rejects a string", () => {
expect(isPerson(42)).toBe(false);
});
it("rejects, if firstName is missing", () => {
const input = { lastName: "Doe" };
expect(isPerson(input)).toBe(false);
});
it("rejects, if firstName is empty", () => {
const input = { firstName: "", lastName: "Doe" };
expect(isPerson(input)).toBe(false);
});
it("rejects, if lastName is missing", () => {
const input = { firstName: "John" };
expect(isPerson(input)).toBe(false);
});
it("rejects, if lastName is empty", () => {
const input = { firstName: "John", lastName: "" };
expect(isPerson(input)).toBe(false);
});
What is immediately noticeable here are the many similar negative test cases ("rejects..."). Overall, the test methods look pretty much the same, except for the input and the test description.
I don't mind having non-DRY code in tests, as each test should work independently and depend as little as possible on others. Nevertheless, it is tedious to adapt each test in case the interface of the SUT or the test code itself changes during the development.
To remove some DRY-ness, we could combine some tests, for example, for primitive types:
it("rejects primitive types", () => {
expect(isPerson(null)).toBe(false);
expect(isPerson(undefined)).toBe(false);
expect(isPerson(true)).toBe(false);
expect(isPerson(false)).toBe(false);
expect(isPerson(42)).toBe(false);
expect(isPerson("foo")).toBe(false);
});
This style is sufficient for one-liner tests but can quickly get out of hand for more complex test bodies. Some people think having multiple assertions in a test is terrible, but I don't see it as black and white.
Let us now reformulate this test into a data-driven test. We achieve this by passing the input to the test code as a parameter. Thanks to the functional nature of Javascript, this is relatively easy:
const primitives = [null, undefined, true, false, 42, "foo"];
primitives.forEach((input) => {
it(`rejects primitive ${JSON.stringify(input)}`, () => {
expect(isPerson(input)).toBe(false);
});
});
We basically iterate over the possible input values and execute each test individually, as in the example shown at the beginning. To do this, we wrap the test in a function that takes an input value as a parameter.
However, I want to reveal one disadvantage of this approach. Your favourite IDE likely refuses to run single tests wrapped in this way. The built-in parser will not recognize them. But as long as we group tests in describe
scopes, we can still execute them in one go using IDE augmentations, which is sufficient for me (I work with WebStorm).
Thanks to JSON.stringify(input)
for building our test name, we can easily include complex test input:
[
...primitives,
{},
[],
{ lastName: "Doe" },
{ firstName: "", lastName: "Doe" },
{ firstName: "John" },
{ firstName: "John", lastName: "" },
].forEach((input) => {
it(`rejects ${JSON.stringify(input)}`, () => {
expect(isPerson(input)).toBe(false);
});
});
By doing that, we condensed our original test suite significantly. To extend that, let's include the positive test cases as well.
[
...[
...primitives,
{},
[],
{ lastName: "Doe" },
{ firstName: "", lastName: "Doe" },
{ firstName: "John" },
{ firstName: "John", lastName: "" },
].map((i) => [i, false] as const), // <- tuple [input, result=false]
[{ firstName: "John", lastName: "Doe" }, true] as const, // <- tuple [input, result=true]
[{ firstName: "John", lastName: "Doe", foo: 42 }, true] as const, // <- tuple [input, result=true]
].forEach(([input, result]) => {
it(`${result ? "accepts" : "rejects"} ${JSON.stringify(input)}`, () => {
expect(isPerson(input)).toBe(result);
});
});
The idea is the same, except a tuple is first built from each input, consisting of the input itself and the expected result. So the test is parameterized based on the input and the desired result.
Many developers (me included) may find that example already too much of a good thing because the higher complexity in preparing the test input is not worth it here. And if we had considerably more positive test cases, we could combine them separately into one parameterized test.
Finally, I want to show you a slightly more complex but simplified example for the sake of brevity. Here we extend our type Person
with an address specification and test a fictitious function isPersonWithAddress
.
type Person = {
firstName: string;
lastName: string;
};
type Address = {
street: string;
postalCode: string;
city: string;
};
type PersonWithAddress = Person & { address: Address };
const primitives = [null, undefined, true, false, 42, "foo"];
const notANonEmptyString: any[] = [
null,
undefined,
"",
42,
true,
false,
[],
{},
];
const samplePersonWithAddress = {
firstName: "John",
lastName: "Doe",
address: {
street: "Mainstreet 42",
postalCode: "8043",
city: "Zurich",
},
};
[
{ prop: "firstName", invalid: notANonEmptyString },
{ prop: "lastName", invalid: notANonEmptyString },
{ prop: "address", invalid: primitives },
{ prop: "address.street", invalid: notANonEmptyString },
{ prop: "address.postalCode", invalid: notANonEmptyString },
{ prop: "address.city", invalid: notANonEmptyString },
].forEach(({ prop, invalid }) => {
invalid.forEach((value) =>
it(`rejects ${prop} = ${JSON.stringify(value)}`, () => {
// we use lodash's `set` to mutate an object property
const input = _.set({ ...samplePersonWithAddress }, prop, value);
expect(isPersonWithAddress(input)).toBe(false);
})
);
});
The initial situation in this example is always the same input (samplePersonWithAddress
) for the function under test (isPersonWithAddress
). In a nested loop, we iterate over invalid values for each property of the test input. Effectively, this is a parameterization in two dimensions: First, the property of the input value to modify and second, invalid values.
Conclusion
Parameterized tests are a valid approach to keep the test code cleaner and DRY. But they are not a silver bullet. Just because it's relatively easy to generate many combinations of input data doesn't mean we should always do so. First and foremost, it is crucial to test modules individually. That way, components that depend on others can be tested with only some possible combinations of input since we already know that the modules they depend on are tested thoroughly. Besides, this would otherwise quickly lead to an exponential combination of possible input data, which is undoubtedly different from what we strive for (meaningful tests). Overall, it is better to align the test parameterization with the logic inherent in the particular module.
Top comments (0)