Saturday, 16 January 2021

Web Tutorial: Basic JavaScript Unit Testing Suite (Part 1/2)

A big part of software development is testing. In particular, automated testing. Unit testing. There are all sorts of frameworks for that now, but back then, I wrote my own.

Recently, I came across that piece of code I wrote back then to test simple JavaScript functions. I spruced it up, made it pretty, and today I submit that to you for your consideration.

Unit testing basically consists of a unit - usually a very small function - for which you determine both the input and the expected output. The program then does an assert - a comparison between the expected output and the actual output - to determine if the test passes or fails.

You see, vanilla JavaScript has no native assert function. So I made one. This little testing suite is painfully basic - you'd probably do better relying on one of the several JavaScript Unit testing frameworks out there. But it does serve to illustrate how things work.

The setup

Here's some boilerplate HTML.
<!DOCTYPE html>
<html>
    <head>
        <title>Unit testing</title>
        <style>

        </style>

        <script>

        </script>
    </head>

    <body>

    </body>
</html>


For this, we will be testing a function, getCircleArea(). It uses a parameter, r, which is the radius, and then a simple formula to calculate the area.
<script>
    function getCircleArea(r)
    {
        let pi = 3.142;

        return pi * r * r;
    }

</script>


But here's the thing about Unit Testing - you have to write code that is testable. That includes taking care of all the edge cases you can think of. What if r is negative? What if it's not a number?
<script>
    function getCircleArea(r)
    {
        if (isNaN(r)) return null;
        if (r < 0) return null;


        let pi = 3.142;

        return pi * r * r;
    }
</script>


That's the function... now let's define the test cases. For this, we use the object testGroups. It's an array of objects. We will have only one group, for now.
<script>
    let testGroups =
    [
        {

        }
    ];


    function getCircleArea(r)
    {
        if (isNaN(r)) return null;
        if (r < 0) return null;

        let pi = 3.142;

        return pi * r * r;
    }
</script>


Each group has a name. Call it anything you want, but in the interest of good organization, I would name it after the function we are testing.
let testGroups =
[
    {
        "name": "getCircleArea"
    }
];


For every group, there are multiple tests. So make tests an array of objects. Each object has these properties:

title - a description of the test. It's a description, so make it descriptive!
args - an array consisting of all the arguments we are passing into the function to be tested.
unit - the function to be tested.
expected - the value of the output.

let testGroups =
[
    {
        "name": "getCircleArea",
        "tests":
        [
            {

            },
            {

            },
            {

            }                        
        ]

    }
];


Below, I have filled in the values for you.
"tests":
[
    {
        "title": "Circle Area should return null if radius is negative.",
        "args": [-5],
        "unit": getCircleArea,
        "expected": null

    },
    {
        "title": "Circle Area should return null if radius is NaN.",
        "args": ['abc'],
        "unit": getCircleArea,
        "expected": null

    },
    {
        "title": "Circle Area should return correct calculation.",
        "args": [5],
        "unit": getCircleArea,
        "expected": (3.142 * 5 * 5)

    }                        
]


Now, create the object unitTesting. It has two methods - prepareDOM() and testUnits().
let testGroups =
[
    {
        "name": "getCircleArea",
        "tests":
        [
            {
                "title": "Circle Area should return null if radius is negative.",
                "args": [-5],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return null if radius is NaN.",
                "args": ['abc'],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return correct calculation.",
                "args": [5],
                "unit": getCircleArea,
                "expected": (3.142 * 5 * 5)
            }                        
        ]
    }
];

let unitTesting =
{
    prepareDOM: function()
    {
    
    },
    testUnits: function()
    {

    }
}


function getCircleArea(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * r;
}


You may as well, at this point, ensure that they fire off when the page is loaded.
<body onload="unitTesting.prepareDOM();unitTesting.testUnits();">

</body>


prepareDOM()
ensures that the DOM has placeholders for your script to populate later. We begin by grabbing the body using the getElementsByTagName() method.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");
},


Then, using a For loop, iterate through the testGroups array.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");

    for (var i = 0; i < testGroups.length; i++)
    {

    }
    
},


Create a fieldset for every element in the array, and append it to the body.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");

    for (var i = 0; i < testGroups.length; i++)
    {
        var groupFieldset = document.createElement("fieldset");

        body[0].appendChild(groupFieldset);

    }    
},


Create a legend tag, insert the name of the group, and append it to the fieldset before appending it to the body.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");

    for (var i = 0; i < testGroups.length; i++)
    {
        var groupFieldset = document.createElement("fieldset");

        var legend = document.createElement("legend");
        legend.innerHTML = testGroups[i].name;
        groupFieldset.appendChild(legend);


        body[0].appendChild(groupFieldset);
    }    
},


Yep, that's how it's done!


Now iterate through the tests array within each group.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");

    for (var i = 0; i < testGroups.length; i++)
    {
        var groupFieldset = document.createElement("fieldset");

        var legend = document.createElement("legend");
        legend.innerHTML = testGroups[i].name;
        groupFieldset.appendChild(legend);

        for (var j = 0; j < testGroups[i].tests.length; j++)
        {

        }

        
        body[0].appendChild(groupFieldset);
    }    
},


Create a div, give it a unique id, and append it to the fieldset. When you rerun the code, you won't see anything new because we haven't populated these divs.
prepareDOM: function()
{
    var body = document.getElementsByTagName("body");

    for (var i = 0; i < testGroups.length; i++)
    {
        var groupFieldset = document.createElement("fieldset");

        var legend = document.createElement("legend");
        legend.innerHTML = testGroups[i].name;
        groupFieldset.appendChild(legend);

        for (var j = 0; j < testGroups[i].tests.length; j++)
        {
            var testDiv = document.createElement("div");
            testDiv.id = "group_" + i + "_test_" + j;
            groupFieldset.appendChild(testDiv);

        }
        
        body[0].appendChild(groupFieldset);
    }    
},


That's for preparing the DOM; now let's actually test these units using the testUnits() method. Declare two variables - result and exceptionThrown. Because when you run functions, two things basically happen. You might get a result, which is either correct or incorrect. You could have an exception thrown, which is definitely unexpected behavior. We want to cater for all these cases.
testUnits: function()
{
    var result, exceptionThrown;
}


So, again, iterate through the testGroups array and within it, iterate through the tests array.
testUnits: function()
{
    var result, exceptionThrown;

    for (var i = 0; i < testGroups.length; i++)
    {
        for (var j = 0; j < testGroups[i].tests.length; j++)
        {

        }
    }

}


Declare str, and make it a string that tells us what function is being run, using the title property. Append the string to the div in question.
testUnits: function()
{
    var result, exceptionThrown;

    for (var i = 0; i < testGroups.length; i++)
    {
        for (var j = 0; j < testGroups[i].tests.length; j++)
        {
            var str = "Running " + testGroups[i].tests[j].title + "<br />";

            document.getElementById("group_" + i + "_test_" + j).innerHTML = str;

        }
    }
}


See what we're doing here? Good.

Now set exceptionThrown to false and result to undefined.
var str = "Running " + testGroups[i].tests[j].title + "<br />";

exceptionThrown = false;
result = undefined;


document.getElementById("group_" + i + "_test_" + j).innerHTML = str;


Then use a Try-catch. In the Try block, set result to the result returned when we pass in args to the function to be run. For this, we use the apply() method.
var str = "Running " + testGroups[i].tests[j].title + "<br />";

exceptionThrown = false;
result = undefined;

try
{
    result = testGroups[i].tests[j].unit.apply(null, testGroups[i].tests[j].args);
}
catch (err)
{

}


document.getElementById("group_" + i + "_test_" + j).innerHTML = str;


In the Catch block, we cater for the case where an exception is thrown. If so, set exceptionThrown to true and make the exception message part of str.
var str = "Running " + testGroups[i].tests[j].title + "<br />";

exceptionThrown = false;
result = undefined;

try
{
    result = testGroups[i].tests[j].unit.apply(null, testGroups[i].tests[j].args);
}
catch (err)
{
    exceptionThrown = true;
    str += err + "<br />";

}
                            
document.getElementById("group_" + i + "_test_" + j).innerHTML = str;


After that, check if exceptionThrown is true. If not, check result. If result is the same as the expected property, the test is a success.
try
{
    result = testGroups[i].tests[j].unit.apply(null, testGroups[i].tests[j].args);
}
catch (err)
{
    exceptionThrown = true;
    str += err + "<br />";
}

if (!exceptionThrown)
{
    if (result == testGroups[i].tests[j].expected)
    {
        str += "Test successful.";
        passed ++;
    }
    else
    {

    }    
}


document.getElementById("group_" + i + "_test_" + j).innerHTML = str;


If not, it's a failure and we want to tell the user why it's a failure by stating what the result was and what we expected instead.
if (!exceptionThrown)
{
    if (result == testGroups[i].tests[j].expected)
    {
        str += "Test successful.";
    }
    else
    {
        str += "Test unsuccessful. Expected " + testGroups[i].tests[j].expected + ", returned " + result;
    }    
}


Check this out!

Now let's deliberately make the function wrong. What happens?
function getCircleArea(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * r * r;
}


Yes, we get a fail case.

Undo that change, and now let's make this guy throw an exception.
function getCircleArea(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * i;
}


Great!

Undo that change as well, and now let's add a second function to be tested. It's the getCircleCircumference() function.
function getCircleArea(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * r;
}

function getCircleCircumference(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * 2;
}


Make a new test group.
let testGroups =
[
    {
        "name": "getCircleArea",
        "tests":
        [
            {
                "title": "Circle Area should return null if radius is negative.",
                "args": [-5],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return null if radius is NaN.",
                "args": ['abc'],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return correct calculation.",
                "args": [5],
                "unit": getCircleArea,
                "expected": (3.142 * 5 * 5)
            }                        
        ]
    },
    {
        "name": "getCircleCircumference"
    }

];


And a new series of tests.
let testGroups =
[
    {
        "name": "getCircleArea",
        "tests":
        [
            {
                "title": "Circle Area should return null if radius is negative.",
                "args": [-5],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return null if radius is NaN.",
                "args": ['abc'],
                "unit": getCircleArea,
                "expected": null
            },
            {
                "title": "Circle Area should return correct calculation.",
                "args": [5],
                "unit": getCircleArea,
                "expected": (3.142 * 5 * 5)
            }                    
        ]
    },
    {
        "name": "getCircleCircumference",
        "tests":
        [
            {
                "title": "Circle Circumference should return null if radius is negative.",
                "args": [-5],
                "unit": getCircleCircumference,
                "expected": null
            },
            {
                "title": "Circle Circumference should return null if radius is NaN.",
                "args": ['abc'],
                "unit": getCircleCircumference,
                "expected": null
            },
            {
                "title": "Circle Circumference should return correct calculation.",
                "args": [5],
                "unit": getCircleCircumference,
                "expected": (3.142 * 5 * 2)
            }                
        ]

    }
];


It's all coming together, isn't it?

This is not complete yet, but we're off to a decent start. It's really bare bones at the moment.

Next

Another function to test, and prettying things up.

No comments:

Post a Comment