Codebox Software

JavaScript Unit Tests

Published:

This is a brief description of a method I've used to make JavaScript code more reliable by allowing it to be unit-tested, the example assumes that you are writing client-side JavaScript that will run in a web-browser.

To illustrate the technique I will convert the following fairly typical piece of form-validation code to allow it to be unit-tested easily. The code is triggered when the user tries to submit a form, it checks the contents of 3 text fields and performs some simple validation on each value, popping up an 'alert' box and cancelling submission of the form if there is something wrong.

function checkForm(){
    var name  = document.getElementById('name').value;
    var age   = document.getElementById('age').value;
    var email = document.getElementById('email').value;
    var errorMsg = '';

    if (name.length===0){
        errorMsg = 'Empty name\n';
    }

    if (age.length===0){
        errorMsg += 'Empty age\n';    
    } else if (isNaN(Number(age))){
        errorMsg += 'Invalid age - enter a number\n';    
    }

    if (email.length===0){
        errorMsg = 'Empty email';    
    } else if (email.indexOf('@') < 0){
        errorMsg = 'Invalid email';    
    }

    if (errorMsg.length > 0){
        alert(errorMsg);
        return false;
    }
}

We want to end up with a way of easily checking various different combinations of input values to see that the code behaves as we would expect, as things stand this is difficult because the code reads values from input fields (so we would need to populate these fields with values before running the code) and because it opens 'alert' dialog boxes in the event of an error (these dialog boxes would need to be checked manually to verify that the messages they contain were correct, and each box would need to be physically clicked in order to remove it from the screen).

The first step involves moving the bits of code that are causing the difficulty out of the function and into a wrapper, then when we want to run our unit tests we can use the extra layer of abstraction introduced by the wrapper to substitute our own test-friendly routines in place of the awkward ones. The problems described above are caused by the document.getElementById and alert functions, so we move these into wrapper functions as shown:

function wrapGetElementById(id){
    return document.getElementById(id);
}

function wrapAlert(msg){
    alert(msg);
}

and make the necessary changes to our main function:

function checkForm(){
    var name  = wrapGetElementById('name').value;
    var age   = wrapGetElementById('age').value;
    var email = wrapGetElementById('email').value;
    var errorMsg = '';

    if (name.length===0){
        errorMsg = 'Empty name\n';
    }

    if (age.length===0){
        errorMsg += 'Empty age\n';    
    } else if (isNaN(Number(age))){
        errorMsg += 'Invalid age\n';    
    }

    if (email.length===0){
        errorMsg = 'Empty email';    
    } else if (email.indexOf('@') < 0){
        errorMsg = 'Invalid email';    
    }

    if (errorMsg.length > 0){
        wrapAlert(errorMsg);
        return false;
    }
}

The code works exactly the same as before, but that small change has made a big difference to its testability. Now we can swap the call to document.getElementById for something like this:

var mockDom = {};
function wrapGetElementById(id){
    // return document.getElementById(id);
    return mockDom[id];
}

Now, when the form code calls the wrapGetElementById() function, instead of returning a reference to an element in the DOM it will return one of the properties of our mockDom object - all we need to do in preparation is make sure that the mockDom object has one property for each of the DOM items that the code will try to access:

mockDom = {
    'name'  : {'value' : name},
    'age'   : {'value' : age},
    'email' : {'value' : email}
};

Because JavaScript is a 'loosely typed' language it doesn't matter that the objects getting returned aren't real DOM elements, as long as they have all the properties that are used by the code it will work just fine. Since our form code just checks the value property of each element, and nothing else, the value property is the only thing we need to supply in our mock objects.

The other obstacle to testability that we have is the fact that the form validation code pops up 'alert' boxes if it finds an error. This problem is also solved very easily by changing the code inside our wrapAlert() function. Now, instead of actually opening an alert dialog box, we just store the text that would have appeared in the box inside the alertMsg variable like so:

var alertMsg = null;
function wrapAlert(msg){
    // alert(msg);
    alertMsg = msg;
}

this approach means that we no longer have lots of 'alerts' popping up as we run our test, and it also means we can programmatically inspect the contents of each alert message produced by the validation code.

We now have all the ingredients needed to fully test our form validation code, however to save us some typing and avoid a lot of repetitious test code I will make a small 'helper' function as follows:

function testForm(name, age, email, expectedError){
    mockDom = {
        'name'  : {'value' : name},
        'age'   : {'value' : age},
        'email' : {'value' : email}
    };
    alertMsg = null;
    checkForm();
    return alertMsg === expectedError;
}

This testForm function accepts name, age and email values - these are the values that we want to put into our mockDom object, to represent the input values supplied by a user in each of the 3 input fields. The function also accepts a fourth value called expectedError - this is the text of the error message (if any) that we expect the specified combination of inputs of produce. So, this helper function lets us test the whole of our input validation code with a single function call - we give it the 3 input values, and the error message that we expect; if the code is behaving as expected then the testForm function will return a value of true otherwise it will return false.

For example, if we enter valid age and email values, but don't have anything in the name field, we would expect to get an error message saying 'Empty name'. We can express this using the following test:

var testPassed = testForm('', '23', 'rob@codebox', 'Empty name\n');

Indeed, we can produce a suite of tests which between them cover all the various error conditions that can arise:

var results = [
    testForm('rob', '23', 'rob@codebox',   null),
    testForm(   '', '23', 'rob@codebox',  'Empty name\n'),
    testForm('rob',   '', 'rob@codebox',  'Empty age\n'),
    testForm('rob', 'xx', 'rob@codebox',  'Invalid age\n'),
    testForm('rob', '23',            '',  'Empty email'),
    testForm('rob', '23', 'rob_codebox',  'Invalid email'),
    testForm(   '',   '', 'rob@codebox',  'Empty name\nEmpty age\n'),
    testForm(   '', '23',            '',  'Empty name\nEmpty email'),
    testForm('rob',   '',            '',  'Empty age\nEmpty email'),
    testForm(   '',   '',            '',  'Empty name\nEmpty age\nEmpty email')
];
    
var pass = 0, fail = 0;
for (var i=0, l=results.length; i<l; i++){
    results[i] ? pass++ : fail++;
}    

alert(pass + ' tests passed, ' + fail + ' tests failed');

This code runs a series of tests and stores the results of each test (either true for a pass, or false for a fail) in the results array. We then loop through the array counting up the number of passes and fails, and display the result. Running these tests produces 7 passes but 3 fails, further investigation will reveal that the final 3 tests produce an error message saying 'Empty email' only, rather than the expected messages contained in the tests. This problem is easily traced to the following section of the checkForm() function:

if (email.length===0){
    errorMsg = 'Empty email';    
} else if (email.indexOf('@') < 0){
    errorMsg = 'Invalid email';    
}

which should in fact read as follows:

if (email.length===0){
    errorMsg += 'Empty email';    
} else if (email.indexOf('@') < 0){
    errorMsg += 'Invalid email';    
}

changing the function and re-running the tests produces 10 passes and 0 fails.

This method can be adapted to handle a wide variety of browser-based code by following the same approach - moving any DOM-based function calls out into separate wrapper functions, then temporarily replacing the calls with test code that either supplies values, or records results, as required. In order to avoid having to edit your code each time you want to run your tests (commenting out DOM calls and replacing them with test code etc) simply define the test versions of your wrapper functions in a separate file - when you want to run tests just include a reference to this test code in a <script> tag after the point in your page where the regular versions of the wrapper functions are defined, the test code functions will override the earlier definitions allowing you to do your testing without editing any scripts.