Steven Senkus

Software Developer living in 🌞 sunny, 🌞 sunny San Diego

Writing Better JavaScript Unit Tests - Scenarios

When you have a function or class method that can take many conditional paths and varying parameters, it can be tempting to write a lot of unit tests to brute force your way to complete code coverage.

While this approach works, you end up repeating common test tasks like setup, initialization, and teardown. Unit test files will end up with a lot of duplication, which will make it difficult to refactor once requirements change.

There’s an easier way: unit test scenarios


In this article, I will show you how to write better unit tests by creating test scenarios. We will start with a fairly simple class that needs to be tested and show different approaches to unit test coverage. At the end, you will see the huge benefit of DRYing up your test code by using test scenarios.

What are unit test scenarios?

In my opinion, unit test scenarios are a more parameterized approach to the setup, execution, and assertion phases of a unit test.

Understanding the black box testing model is critical to understand the purpose of scenario testing. With this mental model, the state of the box is configured during the setup phase. Once the box is in the state we want to test, we provide input to the black box (execution) and then check the output for what we expect (assertion).

Unit test scenarios allow for to test our code more efficiently.
We can create a collection of scenarios with input and output parameters, then iterate through this collection with our execution code.

While this may seem abstract and confusing right now, you’ll see exactly what I mean below.

The class we are testing

For the sake of an example, let’s say that I am trying to achieve 100% code coverage on a class like so:

class TestClass {

    constructor() {
        
    }
    
    initialize() {
        this.items = [{
            type: 'a',
            value: 10,
        }, {
            type: 'b',
            value: 20,
        }, {
            type: 'c',
            value: 50,
        }, {
            type: 'd',
            value: 100,
        }];
    }

    getValueByType(type) {
        const itemByType = this.items.find(value => value.type === type);
        return itemByType.value;
    }

}

export default class TestClass;

The brute force approach to unit testing

If we wanted to ensure 100% code coverage for this class’s getValueByType(type), we would need to test all acceptable parameters to this function.

The brute force approach would be to get one passing unit test, then copy + paste with a little modification for each of the different scenarios (including the test message).

import { TestClass } from './TestClass.js';


describe('TestClass', () => {

    describe('methods()', () => {

        it('should return 10 when type is "a"', () => {
            const testClass = new TestClass();
            testClass.initialize();
            
            const type = 'a';
            
            const result = testClass.getValueByType(type);
            
            expect(result).toEqual(10);
        });
    
        it('should return 20 when type is "b"', () => {
            const testClass = new TestClass();
            testClass.initialize();
            
            const type = 'b';
            
            const result = testClass.getValueByType(type);
            
            expect(result).toEqual(20);    
        });
        
        it('should return 50 when type is "c"', () => {
            const testClass = new TestClass();
            testClass.initialize();
            
            const type = 'c';
            
            const result = testClass.getValueByType(type);
            
            expect(result).toEqual(50);    
        });
        
        it('should return 100 when type is "d"', () => {
            const testClass = new TestClass();
            testClass.initialize();
            
            const type = 'd';
            
            const result = testClass.getValueByType(type);
            
            expect(result).toEqual(100);    
        }); 
    
    });    

});

Look how much repetition there is in this test suite!

Each unit test requires us to create a class instance, initialize it, create a variable for our input, execute the function with the given input, and assert the output.

Having multiple blocks of boilerplate and setup code for tests makes refactoring the actual source code difficult.

The test scenarios approach

Instead, you should use scenarios!

With basic JavaScript iteration and test setup/teardown, we can reduce the complexity of our tests and help enable better refactoring.

import { TestClass } from './TestClass.js';


describe('TestClass', () => {

    describe('methods()', () => {
    
        describe('getValueByType()', () => {

            // We will reuse this variable
            let testClass = null;
            // This will be a reference to our class's test method
            let testFn = null;
            
            // Test scenarios to exercise the method under test
            const scenarios = [{
                type: 'a',
                expectedValue: 10
            }, {
                type: 'b',
                expectedValue: 20        
            }, {
                type: 'c',
                expectedValue: 50    
            }, {
                type: 'd',
                expectedValue: 100          
            }];
            
            beforeEach(() => {
                // Do all of the setup here instead of in each test
                testClass = new TestClass();
                testClass.initialize();
            });
            
            afterEach(() => {
                // not required, but we should reset the testClass to ensure no test bleed!
                testClass = null;
            })
        
            scenarios.forEach((scenario) => {
                // Object destructuring for easier property access
                const { type, expectedValue } = scenario;
                
                // Better test message, less repetition
                it(`should return ${expectedValue} when type is "${type}" `, () => {
                    expect(testClass.getValueByType(type)).toEqual(expectedValue);
                });
                
            });
                
        });
        
    });

});

In this approach, we utilize the beforeEach and the afterEach methods to perform the setup and teardown of our tests. Our setup code handles the tedious work of creating and initializing our class instance for each test. Our teardown code simply sets the testClass instance to null.

Structuring our test code for a scenario is where we will see the most benefit. A scenarios array contains objects with input (type) and expected output (expectedValue) parameters. We then iterate through this collection, executing a test in the forEach() callback.

You will notice that the test message is also parameterized with a template string. This will make it easier to track down which scenarios are failing.

Benefits of unit test scenarios

Identifying obvious sources of errors

Can you see an obvious potential bug?

  • If an instance of this class calls its getValueByType(type) method without first calling initialize(), an error will occur due to the instance’s items array being undefined:
    Uncaught TypeError: Cannot read property 'find' of undefined.

How about another potential error that will occur even if the instance of this class has already called initialize()?

  • That’s right, the getValueByType(type) method does no type-checking on its parameter type. If a string other than 'a', 'b', 'c', or 'd' is passed in, an error will occur: Uncaught TypeError: Cannot read property 'value' of undefined"

Refactoring your codebase

I bet that you can already see a few ways that this simple class can be made better. Refactoring existing code to make it easier to understand and build upon is an important software development practice.

Let’s take a look at some low-hanging fruit for examples of how we can refactor this class:

  • There is absolutely no reason that the initialize() method needs to exist.
    What happens in there (adding an array of items to class property items) should be done in the constructor.
  • Another refactoring task is to update the getValueByType(type) method. This method should check the value of the itemByType variable to ensure that it is an object before trying to access its value property. We could instead return null if no itemByType is found in the items collection array.

Our refactored class

class TestClass {

    constructor() {
        this.items = [{
            type: 'a',
            value: 10,
        }, {
            type: 'b',
            value: 20,
        }, {
            type: 'c',
            value: 50,
        }, {
            type: 'd',
            value: 100,
        }];        
    }

    getValueByType(type) {
        const itemByType = this.items.find(value => value.type === type);
        return (itemByType) ? itemByType.value : null;
    }

}

export default class TestClass;

With our previous test code in place, we can now write more tests to cover our latest improvements.

Conclusion

Unit test scenarios were a game-changer for me when I first got into unit testing.
If you find yourself repeating test code, take a look at adding unit test scenarios to your testing skill set.

Until next time, happy coding!


Share