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();
    });
    
});

Spock

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:

Karma unwrapped numbers

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 :)

Karma unwrapped numbers2

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();
    });

});