Data sets or data providers in testing are powerful tools, which can let you keep your test clean and simple. Checking only happy path doesn't prove your application works as expected. High-quality unit tests need to check many cases with different data. Let us consider such a case:
We hire moderators for keeping the order in our social media service. Each moderator has his own base salary, but for their action they can earn some additional penalties and bonuses. Penalties are expressed with percentage by which the salary will be reduced. While bonuses are just values which will be added to the base salary. Important business logic – the penalties are handled BEFORE bonuses, so even if moderator got 100% penalty, it can still get some money with additional bonuses. Here is the salary calculation equation:
FINAL SALARY = (BASE SALARY - PERCENTAGE OF BASE SALARY) + BONUSES
A simple implementation of business logic described below would look like this:
class SalaryService {
static getFinalSalary(
baseSalary: number,
penalties: number,
bonuses: number
): number {
return baseSalary * (1 - penalties / 100) + bonuses;
}
}
Ok, now is time to coverage our code with some unit tests:
describe('SalaryService', () => {
describe('getFinalSalary', () => {
it('returns calculated final salary', () => {
const result = SalaryService.getFinalSalary(10, 50, 2);
expect(result).toBe(7);
});
});
});
This is a perfectly fine test, it is short and clean. But it doesn't prove that tested code fulfills business requirements because it can just always return 7
. We need to check our method against more than just one case. Three different input sets will be enough for now. So, what we do with our test? Copy and paste like this?
describe('SalaryService', () => {
describe('getFinalSalary', () => {
it('returns calculated final salary', () => {
const result = SalaryService.getFinalSalary(10, 50, 2);
expect(result).toBe(7);
});
it('returns calculated final salary', () => {
const result = SalaryService.getFinalSalary(0, 50, 3);
expect(result).toBe(3);
});
it('returns calculated final salary', () => {
const result = SalaryService.getFinalSalary(20, 100, 1);
expect(result).toBe(1);
});
});
});
It doesn't look good – we duplicate lots of code. And this is simple example, image if it would be something far complicated. Luckily, there is a great solution for such an issue – data sets!
Data sets or data providers allow us to rerun the same test with different sets of input values. So first we should gather our data in one consistent array:
const dataSet = [
[10, 50, 2, 7],
[0, 50, 3, 3],
[20, 100, 1, 1],
];
Then we need to rewrite our test a bit our test. Remove all duplicated code, and leave just one test. Now we pass our dataSet
as argument to .each()
at test implementation or test suit level. In the callback, we will receive parameters with values passed in each row of our data set:
describe('SalaryService', () => {
describe('getFinalSalary', () => {
const dataSet = [
[10, 50, 2, 7],
[0, 50, 3, 3],
[20, 100, 1, 1],
];
it.each(dataSet)('returns calculated final salary', (baseSalary, penalties, bonuses, expectedValue) => {
const result = SalaryService.getFinalSalary(baseSalary, penalties, bonuses);
expect(result).toBe(expectedValue);
});
});
});
Ok, it looks better now – we don't have code duplication anymore, and we test many cases with one more generic test. But when you look at our data set, you will probably find it quite hard to read. Without checking the callback arguments, we don't have any what each value represents. Let's fix it.
const dataSet = [
{ baseSalary: 10, penalties: 50, bonuses: 2, expectedValue: 7},
{ baseSalary: 0, penalties: 50, bonuses: 3, expectedValue: 3},
{ baseSalary: 20, penalties: 100, bonuses: 1, expectedValue: 1},
];
As you can see, we've replaced our nested arrays with much more explicit objects. Now everyone, who looks at this data set, will understand what it contains. We need also to change the way how these values are passed to our test body. Change:
(baseSalary, penalties, bonuses, expectedValue)
to destructuring assignment:
({ baseSalary, penalties, bonuses, expectedValue})
You can also use data set values in test description – it can be helpful when some test won't pass. This is what our refactored test case look like. Now we can say it's data-driven test!
describe('SalaryService', () => {
describe('getFinalSalary', () => {
const dataSet = [
{ baseSalary: 10, penalties: 50, bonuses: 2, expectedValue: 7 },
{ baseSalary: 0, penalties: 50, bonuses: 3, expectedValue: 3 },
{ baseSalary: 20, penalties: 100, bonuses: 1, expectedValue: 1 },
];
it.each(dataSet)(
'returns calculated final salary ($baseSalary, $penalties, $bonuses)',
({ baseSalary, penalties, bonuses, expectedValue }) => {
const result = SalaryService.getFinalSalary(
baseSalary,
penalties,
bonuses
);
expect(result).toBe(expectedValue);
}
);
});
});
Now, when you get any errors related to tested method, it's going to be very easy to add another case which will cover it. Remember – always write your test against as many worthwhile cases as you can invent!
Ps. Data sets support is included in Jest since version 23. If for some reasons you're still using an older build, check jest-each npm package, which provides the same functionality.
Top comments (2)
Is there a way for the data set to be an object so that each test case can have a key?
You mean object instead of array?