Monday 18 January 2021

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

In the previous part to this tutorial, we wrote two functions for testing. Those were fairly straightforward cases. Now, what if you had a different kind of function to test, one with a potentially unlimited number of parameters?

I present to you, getAverage(). It accepts one argument - an array of multiple values - and returns the mean of all these values.
function getCircleCircumference(r)
{
    if (isNaN(r)) return null;
    if (r < 0) return null;

    let pi = 3.142;

    return pi * r * 2;
}

function getAverage(arr)
{
    var total = 0;

    for (var i = 0; i < arr.length; i++)
    {
        total += arr[i];
    }

    return total / arr.length;
}


For this, we also need to cater for three cases. First, if there are no values in the array. Return null.
function getAverage(arr)
{
    if (arr.length == 0) return null;

    var total = 0;

    for (var i = 0; i < arr.length; i++)
    {
        total += arr[i];
    }

    return total / arr.length;
}


Second, if any of the values in the array are negative or not a number. Also return null.
function getAverage(arr)
{
    if (arr.length == 0) return null;
    if (arr.filter(function(x) { return x < 0 || isNaN(x); } ).length > 0) return null;

    var total = 0;

    for (var i = 0; i < arr.length; i++)
    {
        total += arr[i];
    }

    return total / arr.length;
}


And finally, if there's only one value in the array. Then simply return that value.
function getAverage(arr)
{
    if (arr.length == 0) return null;
    if (arr.filter(function(x) { return x < 0 || isNaN(x); } ).length > 0) return null;
    if (arr.length == 1) return arr[0];

    var total = 0;

    for (var i = 0; i < arr.length; i++)
    {
        total += arr[i];
    }

    return total / 0;
}


And then we add another test group to testGroups. Call it "getAverage" or whatever.
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)
            }                        
        ]
    },
    {
        "name": "getAverage",
    }

];


And here, I've populated the tests array. Pay attention to the args and expected properties.
{
    "name": "getAverage",
    "tests":
    [
        {
            "title": "Average should return null if empty array.",
            "args": [[]],
            "unit": getAverage,
            "expected": null
        },
        {
            "title": "Average should return null if any values in array are negative.",
            "args": [[5, -5, 0]],
            "unit": getAverage,
            "expected": null
        },
        {
            "title": "Average should return null if any values in array are NaN.",
            "args": [[5, 15, "x"]],
            "unit": getAverage,
            "expected": null
        },
        {
            "title": "Average should return x if x is the ony value.",
            "args": [[100]],
            "unit": getAverage,
            "expected": 100
        },
        {
            "title": "Average should return average of all numbers in array.",
            "args": [[0, 2, 10, 4]],
            "unit": getAverage,
            "expected": 4
        }                        
    ]

}


Now refresh, and let's ruminate on just how easy it is to add tests now that we've done the hard bit.


We should really test for errors and exceptions, but let's do it on a sightly less... ugly interface? Create the CSS class, group. Give it nice round corners, a grey border, the works. Set font and shit. Have at it.
<style>
    .group
    {
        width: 90%;
        height: auto;
        border: 1px solid #CCCCCC;
        border-radius: 5px;
        font-family: arial;
        font-size: 14px;
    }

</style>


In the prepareDom() method, add this line to set each fieldset's class to group.
for (var i = 0; i < testGroups.length; i++)
{
    var groupFieldset = document.createElement("fieldset");
    groupFieldset.className = "group";

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


What a dramatic difference a little bit of styling makes, eh?


Next up, we style the test output. Create the CSS classes test, success and failure.
<style>
    .group
    {
        width: 90%;
        height: auto;
        border: 1px solid #CCCCCC;
        border-radius: 5px;
        font-family: arial;
        font-size: 14px;
    }

    .test
    {

    }

    .success
    {

    }

    .failure
    {

    }

</style>


For test, I've elected for more rounded borders and a font color of white. For success, I use a bright green and red for failure. Choose your own colors!
<style>
    .group
    {
        width: 90%;
        height: auto;
        border: 1px solid #CCCCCC;
        border-radius: 5px;
        font-family: arial;
        font-size: 14px;
    }

    .test
    {
        width: 90%;
        height: auto;
        margin: 5px auto 0 auto;
        border-radius: 3px;    
        color: #FFFFFF;    
        padding: 1em;
        font-size: 0.8em;
    }


    .success
    {
        background-color: #00FF00;    
    }

    .failure
    {
        background-color: #FF0000;
    }

</style>


Back to the nested For loop in the prepareDom() method. Add this line so that each div with test output is now styled with test.
for (var i = 0; i < testGroups.length; i++)
{
    var groupFieldset = document.createElement("fieldset");
    groupFieldset.className = "group";

    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.className = "test";
        testDiv.id = "group_" + i + "_test_" + j;
        groupFieldset.appendChild(testDiv);
    }

    body[0].appendChild(groupFieldset);
}


Now, remember the Try-catch block in the testUnits() method? Add this line to handle failure. It will style the div with failure in addition to test.
try
{
    result = testGroups[i].tests[j].unit.apply(null, testGroups[i].tests[j].args);
}
catch (err)
{
    exceptionThrown = true;
    document.getElementById("group_" + i + "_test_" + j).className = "test failure";
    str += err + "<br />";
}


You guessed it - we'll add the appropriate CSS classes in these cases too.
try
{
    result = testGroups[i].tests[j].unit.apply(null, testGroups[i].tests[j].args);
}
catch (err)
{
    exceptionThrown = true;
    document.getElementById("group_" + i + "_test_" + j).className = "test failure";
    str += err + "<br />";
}

if (!exceptionThrown)
{
    if (result == testGroups[i].tests[j].expected)
    {
        document.getElementById("group_" + i + "_test_" + j).className = "test success";
        str += "Test successful.";
    }
    else
    {
        document.getElementById("group_" + i + "_test_" + j).className = "test failure";
        str += "Test unsuccessful. Expected " + testGroups[i].tests[j].expected + ", returned " + result;
    }    
}


And the screen is awash with green!


Let's try to produce errors and exceptions, but instead of messing with the functions, let's do it by tampering with the tests. In the final test of the group named "getAverage", change the expected value.
{
    "title": "Average should return average of all numbers in array.",
    "args": [[0, 2, 10, 4]],
    "unit": getAverage,
    "expected": 0
}    


Here, the screen tells you what's what!


Change the value back to 4, and now in the args array, change it to a null instead of an array.
{
    "title": "Average should return average of all numbers in array.",
    "args": [null],
    "unit": getAverage,
    "expected": 4
}    


Here you see the exception message!


One last thing!

It's all well and good now, but if you have a lot of tests in a group, you generally want to know how many tests passed. And manually counting is just a little less lazy than a good programmer should aspire to be.

In the prepareDOM() method, add an id to the legend when it's created.
var legend = document.createElement("legend");
legend.id = "legend_" + i;
legend.innerHTML = testGroups[i].name;
groupFieldset.appendChild(legend);


In the testUnits() method, make the following changes. Within the nested For loop, declare passed and set it to 0. This means passed will be 0 at the start of every test group.
for (var i = 0; i < testGroups.length; i++)
{
    var passed = 0;

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


Every time a test is passed, increment passed.
if (!exceptionThrown)
{
    if (result == testGroups[i].tests[j].expected)
    {
        document.getElementById("group_" + i + "_test_" + j).className = "test success";
        str += "Test successful.";
        passed ++;
    }
    else
    {
        document.getElementById("group_" + i + "_test_" + j).className = "test failure";
        str += "Test unsuccessful. Expected " + testGroups[i].tests[j].expected + ", returned " + result;
    }    
}


And at the end of the outer loop, use passed to set the legend within the fieldset.
        if (!exceptionThrown)
        {
            if (result == testGroups[i].tests[j].expected)
            {
                document.getElementById("group_" + i + "_test_" + j).className = "test success";
                str += "Test successful.";
                passed ++;
            }
            else
            {
                document.getElementById("group_" + i + "_test_" + j).className = "test failure";
                str += "Test unsuccessful. Expected " + testGroups[i].tests[j].expected + ", returned " + result;
            }    
        }

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

    document.getElementById("legend_" + i).innerHTML = testGroups[i].name + " (" + passed + "/" + testGroups[i].tests.length + ")";
}


Great work. We can now see how many tests passed in each group!



Some last words...

I can't stress this enough - if you want to do serious automated unit testing, this will not be adequate. The purpose of this exercise was really to illustrate a very basic testing process, and hopefully give you an appreciation of just how much automated testing accomplishes.

Wishing you all the very test!
T___T

No comments:

Post a Comment