Karma and Jasmine are one of the most popular combinations which help developers to test front-end code. To run the same test with different data input (data-driven testing)
we can use a loop with it
inside, or we can import one of the available library, but today I’ll show you how to make that smartly by own hands.
All examples here are in default Angular language - typescript.
Our goals:
- We would like to run a single test with many input cases
- We should be able to see excellent visual results
- Test invocation should consist with Jasmine flow
The first shot
export function using(values: any[],
name: string,
func: (value: any) => void) {
it(name, () => {
for (const i of Object.keys(values)) {
func.apply(func, [values[i]]);
}
}, timeout);
}
The example of usage:
describe('DataDriven', () => {
using([1, 2, 3], 'should process numbers', (number) => {
expect(number).toBeTruthy();
});
});
Okay, what we’ve got here? We’ve created the using
function which takes values
array as an argument, and it provides multiple input cases.
We are able now to run a single test with different parameters - it’s nice but it still has some place for improvements.
The first proposition is to add an option to make each parametrized test visible in test results. We can add some argument which tells function if test cases should be wrapped or unwrapped. Let’s go for that.
export function using(values: any[],
name: string,
func: (value: any) => void,
unwrapped: boolean = true) {
if (unwrapped) {
describe(name, () => {
for (const i of Object.keys(values)) {
it('#' + values[i], func.bind(this, values[i]));
}
});
} else {
it(name, () => {
for (const i of Object.keys(values)) {
func.apply(func, [values[i]]);
}
});
}
}
Now if we going to run following test:
using([1, 5, 6], 'should process numbers', (number) => {
expect(number).toBeTruthy();
});
We’ve got pretty good visual result:
Excellent, a little summary:
- we can run single test logic for multiple input values
- we can run multiple cases as a singular test, and we can run separate test per case too
Please notice that we’ve missed default done()
parameter in it
function but there is no sense to run it inside each execution. If we need to process some async flow we can, for example, add done()
inside using
function after whole flow.
Okay so we’ve got something it would work but… I feel like this using
function may be better, and there is one significant disadvantage: we are not able to put multiple test case parameters per test invocation.
More improvements
The perfect situation is if we’ll able to put differently named parameters, so our test invocation may look like that:
using({
number: [1, 3, 4],
expectedResult: [10, 15, 20]
}, 'should be divisible by div', (number, div) => {
expect(number % div).toEqual(0);
}, false);
How can we achieve something like that? the way to go is the following:
// Helper method to iterate input data object values as pairs
const forEachArgumentsRowIn = (values: { [key: string]: any[] } | any[],
fn: (...argsRow: any[]) => void) => {
while (values[Object.keys(values)[0]].length) {
const args = Object.keys(values).reduce((prev, cur) => {
return prev.concat([values[cur].shift()]);
}, []);
fn(args);
}
};
export function using(input: { [key: string]: any[] } | any[],
name: string,
func: (done: DoneFn) => void,
unwrapped: boolean = true) {
if (unwrapped) {
describe(name, () => {
if (input instanceof Array) {
for (const i of Object.keys(input)) {
it('#' + input[i], func.bind(this, input[i]));
}
} else {
forEachArgumentsRowIn(input, (argsRow: any[]) => {
it(argsRow.reduce((prev, cur, ind) => {
return prev + Object.keys(input)[ind] + '=' + cur + ', ';
}, ''), func.bind(this, ...argsRow));
});
}
});
} else {
it(name, () => {
if (input instanceof Array) {
for (const i of Object.keys(input)) {
func.apply(func, [input[i]]);
}
} else {
forEachArgumentsRowIn(input, (argsRow) => {
func.apply(func, [...argsRow]);
});
}
});
}
}
Now our results look much better :)
The final refactoring
Would you like to have the function more similar to it
function? - I have something for you. We can move our input at the end of invocation. We need to do some lazy-execution stuff to achieve that. Let’s try
export function its(name: string, func: (...args: any[]) => void) {
return {
using: (values: { [key: string]: any[] } | any[],
unwrapped: boolean = true) => using(values, name, func, unwrapped)
};
}
Summary
Uff, finally we’ve finished :) The end of this post is an excellent place to put examples of our final its
function.
describe('DataDrivenTest', () => {
its('should get result for input', (input, result) => {
expect(input[0]).toEqual(result);
}).using({
input: ['super_input1', 'abc'],
result: ['s' , 'a' ]
});
its('should run simple data test', (data) => {
expect(data).toBeTruthy();
}).using([1, 2, 3]);
its('should run test wrapped', (data) => {
expect(data).toBeTruthy();
}).using([1, 2, 3], false);
// or just
using({
number: [1, 5, 6]
}, 'should process numbers unwrapped', (number) => {
expect(number).toBeTruthy();
});
});