Enhancing Test Suite Robustness

By. Guy Y.

Dec 14, 2017

There are a lot of great benefits from unit testing.

It’s important to keep in mind that as the code base changes over time, so does the test code.

Some tests are written as part of the TDD (test-driven development) methodology; some are added after to verify older code.

A good test suite is easy and fast to maintain, refactor and expand.

In this article, we’ll look at some common patterns that could add robustness to your test suites.

Not Unit Testing

The worse thing than a bad test suite is no test suite at all.

Clean Code

It is optimal to maintain the best general coding practices, the same as the production code.
Principles (such as DRY, meaningful names, and low ABC score) should be followed.

Testing only the Golden Scenario

The golden scenario test proves that the function does what it is designed to do, but doesn’t try hard enough to prove otherwise.

It doesn’t take into account things like typing issues, invalid inputs, logical thresholds, and so on.

const divide = (x, y) => x / y;

describe('divide', () => {
it('should divide first number by the second', () => {
expect(divide(1,2)).toEqual(0.5);
})
});

Basically, we expect divide to behave like (number, number) => number. But that isn’t the case:

divide(1,0)
=> Infinity
divide(0,null)
=> NaN
divide('1',5)
=> 0.2

A more robust test suite will include edge cases:

describe('divide', () => {
describe('when input is valid', () => {
describe('when divider is zero', () => {
...
})

describe('when divider is not zero', () => {
...
})
});

describe('when input is not valid', () => {
...
});
});

Note that both test suites provide 100% test coverage. The second one provides better line hits.

Partial Code Coverage

Test coverage issues can be found easily by reviewing the code coverage reports.

const func = (obj) => {
if (obj.hasValue) {
// do something
}
// do more stuff
return 'done'
}

describe('func', () => {
it('does something', () => {
const obj = {}
expect(func(obj)).toEqual('done');
});
});

The test doesn’t cover all logical branches of the function, in this case the do something block.

Multiple setups

Multiple setups in a single test case make it harder to understand which input caused the test to fail.

describe('foo', () => {
it('does something', () => {
let obj = {}
expect(foo(obj)).toEqual(...);
obj = {value: 1}
expect(foo(obj)).toEqual(...);
obj = {value: 2}
expect(foo(obj)).toEqual(...);
obj = {value: 3}
});
});

It’s better to break it down:

describe('foo', () => {
describe('when value is a number', () => {
it('does something when value is 1', () => {
const obj = {value: 1}
expect(foo(obj)).toEqual(...);
});

it('does something when value is 2', () => {
const obj = {value: 2}
expect(foo(obj)).toEqual(...);
});

it('does something when value is 3', () => {
const obj = {value: 3}
expect(foo(obj)).toEqual(...);
});
});
});

Tests with Side Effects

Tests should have no impact on one another. The number/order of test runs should not matter.

describe('inc', () => {
let obj;
it ('should init obj.num', () => {
obj = {};
inc(obj)
expect(obj.num).toBe(0);
});

it ('should increment obj.num', () => {
inc(obj)
expect(obj.num).toBe(1);
});
});

The second test will only pass after the first has run. This can be solved by:

describe('inc', () => {
let obj;
beforeEach(() => {
obj = {};
})

it ('should init obj.num', () => {
inc(obj)
expect(obj.num).toBe(0);
});

it ('should increment obj.num', () => {
obj.num = 0
inc(obj);
expect(obj.num).toBe(1);
});
});

Large setups in a test

For placing large setup procedures in a test:

  1. Distract the reader from the test core.
  2. Make it harder to write future tests on the same subject.
describe('func', () => {
it('shuold do something', () => {
subject = new Something()
/* 
* 10 lines of setup code
*/
expect(subject.run).toEqual(...)
})
});

At some point, another developer will need to add logic to the Something class.
He or she doesn’t want to go over the whole setup procedure, thus wasting time. But it doesn’t really matter for the task. So, he or she copies it, breaking the DRY rule.

describe('func', () => {
it('should run', () => {
subject = new Something()
/* 
* 10 lines of setup code
*/
expect(subject.run()).toEqual(...)
})

it('should start', () => {
subject = new Something()
/* 
* 10 lines of setup code
*/
expect(subject.start()).toEqual(...)
})
});

We can prevent that by making the original test cleaner:

describe('func', () => {
const subject;
beforeEach(() =>{
subject = new Something()
/* 
* 10 lines of setup code
*/
})

it('should do something', () => {
expect(subject.run()).toEqual(...)
})
});

Or by moving the setup into a function:

describe('func', () => {
const setup = (subject) => {
/* 
* 10 lines of setup code
*/
}

it('should do something', () => {
const subject = new Something()
setup(subject);
expect(subject.run()).toEqual(...)
})
});

Testing Interactions instead of State

This subject was covered in my previous post.

Leave a Reply

Your email address will not be published.