Ransford Okpoti's Blog

24 June, 2011

A Practical Introduction to Client-side JavaScript Unit Testing Using QUnit

Filed under: JavaScript — Tags: , , , — ranskills @ 10:55 am

“Wow, I thought I had that working”, how often do you say that to yourself or hear a colleague ranting and raving about this. Well, things break all the time in the software development cycle and you will need to have a consistent, unobtrusive and quick approach to finding out if critical features of an application is malfunctioning.

Over the past few years, automated application testing (unit testing, etc) has been associated with the traditional server-side programming languages, server-side because am discussing this in the web development context, such as Java, PHP, Python, any of the .NET languages. These languages have enjoyed testing frameworks that have been used on enterprise projects by teams that know and recognize the importance of testing on such large-scale projects where the risks are high. Testing is a vital ingredient in any important project, be it large-scale or not. At least, it gives you the added confidence that the application still performs like it was designed to, and as a developer, it is a requisite skill for you to possess. It also serves as a quality measure and aids in detecting points of failures in the application.

Below are some testing frameworks for some server-side programming languages:

PHP

Java

.NET

For a more comprehensive list, check Wikipedia’s List of Unit Testing Frameworks.

JavaScript, the most popular scripting language on the internet, has not been left out in all of these, there are quite a number of unit testing frameworks namely:

In this demonstration, I will be using jQuery‘s unit testing framework known as QUnit.
NOTE: Unit Testing is not difficult, you test an actual result against an expected result and that is it, nothing magical or mythical about it. So, do not think it is for the Zoros, the Rambos, the James Bonds of programming? Definitely not!

Show Me Some Code, Please!

Ok, I will be using two different examples to demonstrate how unit testing is done, i hope the examples satisfy a broad range of readers. Before I get started, you need to set up the initial project folder structure by doing the following, if you want to code along:

  1. Download QUnit by clicking on this link.
  2. Uncompressing the saved file should show a similar directory structure below.
  3. QUnit Download Entries

  4. Setup a folder structure like the one below for the purposes of this demonstration.
    1. Create the project folder called js_unit_testing
    2. Inside the folder created above, create a test folder with the name test, this will contain the unit tests we will be writing and the html file(s) to run the tests
    3. From the uncompressed file, copy the qunit folder into the project folder js_unit_testing and the test/index.html file into the test folder created above in step 3.2
    4. Your project folder structure should looking like this.

      Project Directory Initial Structure

      Project Directory Initial Structure

Now, with the project structure out of the way, lets do some real coding.

Example 1

A trivial example meant to demonstrate unit testing of a function that sums up numbers. This is a good place to start since it does not involve any thinking at all, at least i presume we can all do simple summation, like 1 + 3 = 4, 0 + 0 = 0, 60 + 40 + 1 = 101, etc.
In addition, our function should be able to sum up any number of numbers passed to it and throw an exception if any of the arguments is not a number.

  1. Create num.js file in the project folder with the content below.
  2. /**
     * A vararg function that sums up numbers
     * @return {Number}
     * @throws {Error} If any of the arguments is not strictly a number
     */
    function sum() {
        var total = 0,
            num = 0,
            numArgs = arguments.length;
        
        if (numArgs === 0) { 
            throw new Error('Arguments expected');
        }
        
        for (var i = 0; i < numArgs; i++) {
            num = arguments[i];
            if (typeof (num) !== 'number') {
                throw new Error('Only numbers are allowed but found ', typeof (num));
            }
            
            total += num;
        }
        
        return total;
    }
    

    Now we have to test the sum function. Note: a good test should not only test for situation that always make the program work, but should be broad enough to test for conditions that could actually let the test result in failures to see how well the code deals with such scenarios. A good test should always lead to the detection of errors in a program so that if can be fixed.

  3. Inside the test folder, create test.js, all tests will go here, with the content below.
  4. test("testSumWithValidArguments", function() {
        equal(sum(0, 0), 0, '0+0 = 0');
        equal(sum(2, 2), 4, '2+2 = 4');
        equal(sum(10, -1), 9, '10 + -1 = 9');
        equal(sum(-10, -1), -11, '-10 + -1 = -11');
        equal(sum(2,1, 5, 6), 14, '2 + 1 + 5 + 6 = 14');
        equal(sum(2.3, 1.5), 3.8, '2.3 + 1.5 = 3.8');
        
        notEqual(sum(2, 1), 5, '2 + 1 <> 5');
    });
    
    
    test("testSumWithNoArguments", function() {
        raises(function(){ sum(); }, 'Exception thrown');
    });
    
    test("testSumWithWrongDataToResultInException", function() {
        raises(function(){sum(2,'Ghana')}, 'String rejected');
        raises(function(){sum(2,{num:3})}, 'Object rejected');
    });
    

    Now, lets see the magic at work by opening the index.html in the test folder in the browser. Below is the screenshot of the output.

    Sum Test Successful

    Now to the beauty of this whole process, that is a convenient way to detect defects in the application. Let’s say, a colleague somehow deleted the line that throws an exception when no arguments are passed to the sum() function. On running the test again, the test for that scenario, testSumWithNoArguments, will fail as captured below.

    Sum Test Failure

Example 2

This second demonstration closely models a real world programming scenario.

A government agency has tasked you to develop an application to enable them keep track of all quasi-government institutions/companies. They should be able to add any number of departments to a particular company and also keep track of a company’s employees to enable the prompt notification of those who are due for retirement.
Here are some constraints/features for the system:

  1. A department can only be associated with a company once.
  2. A person who does not meet the minimum age of employment for a company must not be employed.
  3. Employee ids must be automatically generated for those who have not manually been assigned ids.

UML Diagram
The simplified UML Class diagram above depicts some of attributes of the models identified for the scenario in this example and their relationships. The diagram was generated using yUML, an online tool for creating and publishing simple UML diagrams. You can use the DSL codes below to regenerate the UML Class diagrams above on the site.


[note: A simple UML diagram{bg:cornsilk}]
[Company|MIN_AGE_OF_EMPLOYMENT=20;name;departments|addDepartments();employ();getTotalMonthlySalary()],[Department|id;name;employees|getTotalMonthlySalary();equals()],[Employee|firstName;lastName;birthYear;salary|ageAge();getName();toString()]
[Company]1-1..*[Department]
[Department]1-1..*[Employee]

Cool, huh?

Lets get into action.

  1. Create a new folder named app in the main project directory.
  2. Create model.js file in app with the content below. This file contains our three domain objects namely: Company, Department and Employee
  3. /** @namespace All entities in the application are defined here */
    var model = model || {};
    
    /**
     * @class Creates a new Employee instance
     */
    model.Employee = function (data) {
        data = data || {};
    
        /**
         * The first name of the employee
         * @property {String} firstName
         */
        this.firstName = data.firstName || '';
    
        /**
         * The last name or surname of the employee
         * @property {String} lastName
         */
        this.lastName = data.lastName || '';
    
        /**
         * The birth year of the employee
         * @property {Integer} birthYear
         */
        this.birthYear = data.birthYear ||  new Date().getFullYear();
    
        /**
         * The monthly salary of the employee
         * @property {Number} salary
         */
        this.salary = data.salary || 0;
    
        /**
         * @return {Integer} The age of the employee
         */
        this.getAge   = function () {
            return new Date().getFullYear() - this.birthYear;
        };
    
        /** @return {String} The name of the employee */
        this.getName  = function () {
            return this.firstName + ' ' + this.lastName;
        };
    
        /** @return {String} A string representation of this object */
        this.toString = function () {
            return this.getName();
        };
    };
    
    /**
     * @class Creates a new Department instance
     *
     * @param {String} name The name of the department
     * @param {Integer} [id=AUTO_GENERATED]
     */
    model.Department = function (name, id) {
        /** 
    	 * The id of the department which is auto generated when set blank
    	 * @property {Integer} id
    	 */
        this.id = id || parseInt(Math.random() * 100, 10);
    
        /**
    	 * The name of the department
    	 * @property {String} name
    	 */
        this.name = name;
        
        /** The company the department belongs to */
        this.company = null;
    
        /** List of employees in the department */
        this.employees = [];
    
        /**
         * @param {model.Employee} employee The employee to join the department
         */
        this.addEmployee = function (employee) {
            this.employees.push(employee);
        };
    
        /**
         * @return {Double} Total monthly salary for the department
         */
        this.getTotalMonthlySalary = function () {
            var total = 0;
            this.employees.forEach(function (employee) {
                total += employee.salary;
            });
    
            return total;
        };
    
        /**
         *
         * @param {Department} obj
         * @return {Boolean} true if the objects are the same, false otherwise
         */
        this.equals = function (obj) {
            return this.id === obj.id;
        };
    };
    
    /**
     * @class Creates a new Company instance
     */
    model.Company = function (data) {
        data = data || {};
    
        /** The minimum age of employment */
        this.MIN_AGE_OF_EMPLOYMENT = data.MIN_AGE_OF_EMPLOYMENT || 20;
    
        /**
         * @property {String} name The name of the company
         */
        this.name = data.name;
    
        /** List of departments making up the company */
        this.departments = [];
        
        /**
         * Adds a new department to the company
         * @param {model.Department} department
         * @throws {exception.DepartmentExistException} Department already exist
         */
        this.addDepartment = function (department) {
    
            this.departments.forEach(function (item) {
                if (department.equals(item)) {
                    throw new exception.DepartmentExistException(department.name);
                }
            });
    
            department.company = this;
            this.departments.push(department);
        };
    
        /**
         *
         * @param {model.Employee} employee
         * @param {model.Department} department
         * @throws {exception.EmployeeException} Exception thrown if the person fails any of the required criteria for employment
         */
        this.employ = function (employee, department) {
            if (employee.getAge() < this.MIN_AGE_OF_EMPLOYMENT) {
                throw new exception.EmployeeException('Employee must be at least ' + this.MIN_AGE_OF_EMPLOYMENT + ' years');
            }
    
            department.addEmployee(employee);
        };
    
        /**
         * @return {Double} Total monthly salary for the company
         */
        this.getTotalMonthlySalary = function () {
            var total = 0;
            this.departments.forEach(function (department) {
                total += department.getTotalMonthlySalary();
            });
    
            return total;
        };
    };
    
    
    
  4. Next, lets throw in some exceptions like you would do on the server-side by creating exception.js file with the content below.
  5. /** @namespace All exceptions thrown in the application are defined here */
    var exception = exception || {};
    
    /** @class Exception */
    exception.DepartmentExistException = function (value) {
        this.value = value;
        this.name = 'exception.DepartmentExistException';
        this.message = ' department already exist';
    
        this.toString = function () {
            return this.value + this.message;
        };
    };
    
    /** @class Exception */
    exception.EmployeeException = function (message) {
        this.name = 'exception.EmployeeException';
        this.message = message;
    
        this.toString = function () {
            return this.value + this.message;
        };
    };
    
  6. Finally, lets write some unit tests by adding the codes below to our test.js.
  7. module("model", {
        company: new model.Company({name: 'Coders4Africa'}),
    
        infoSys: new model.Department('Information System', 1),
        hr: new model.Department('Human Resource'),
        internalAudit: new model.Department('Internal Audit'),
    
        setup: function () {
            ok(this.company instanceof model.Company, 'Created company is of type model.Company');
        }
    });
    
    test("Employee", function () {
        var empInstance = new model.Employee({firstName: 'Ransford', lastName: 'Okpoti', birthYear: 1990, salary: 2000});
    
        ok(empInstance instanceof model.Employee, 'Created employee is an instance of model.Employee class');
        equal(empInstance.getName(), 'Ransford Okpoti', 'getName() function passed');
        ok(empInstance.getAge() >= 0, 'age should never be negative');
    });
    
    test("Department", function () {
        ok(this.infoSys instanceof model.Department, 'Created object is an instance of model.Department class');
        equal(this.infoSys.getTotalMonthlySalary(), 0, 'Total monthly salary for a new department is 0');
        equal(this.infoSys.company, null, 'Company is null for a newly created department');
        ok(this.infoSys.equals(this.infoSys), 'A department should pass the equals() test on itself');
        ok(!this.infoSys.equals(this.hr), 'Two different departments must not be the same');
        ok(typeof (this.internalAudit.id) === 'number' && this.internalAudit.id > 0, 'Auto-generated id is a positive integer');
    });
    
    test("Company", function () {
        var that        = this,
            year        = new Date().getFullYear(), // current year
            leger       = new model.Employee({firstName: 'Leger', lastName: 'Djiba', birthYear: year - 30, salary: 930}),
            amadou      = new model.Employee({firstName: 'Amadou', lastName: 'Daffe', birthYear: year - 35, salary: 850}),
            khalil      = new model.Employee({firstName: 'Ibrahima', lastName: 'Ndiaye', birthYear: year - 40, salary: 68}),
            kwame       = new model.Employee({firstName: 'Kwame', lastName: 'Andah', birthYear: year - 30, salary: 51}),
            abderemane  = new model.Employee({firstName: 'Abderemane', lastName: 'Abdou', birthYear: year - 50, salary: 41}),
            ranskills   = new model.Employee({firstName: 'Ransford', lastName: 'Okpoti', birthYear: year - 22, salary: 40}),
            prince      = new model.Employee({firstName: 'Prince', lastName: 'Nyarko', birthYear: year - 42, salary: 33}),
            james       = new model.Employee({firstName: 'James', lastName: 'Gaglo', birthYear: year - 50, salary: 32}),
            rufin       = new model.Employee({firstName: 'Rufin', lastName: 'Slyvestre', birthYear: year - 53, salary: 31}),
            mohammed    = new model.Employee({firstName: 'Mohammed-Sani', lastName: 'Abdulai', birthYear: year - 15, salary: 27});
    
        equal(this.company.departments.length, 0, 'Departments should be empty for a newly created company');
        equal(this.company.getTotalMonthlySalary(), 0, 'Total monthly salary for a new company is 0');
    
        this.company.addDepartment(this.infoSys);
        this.company.addDepartment(this.hr);
        this.company.addDepartment(this.internalAudit);
    
    
        this.company.employ(leger, this.infoSys);
        this.company.employ(amadou, this.infoSys);
        this.company.employ(khalil, this.infoSys);
    
        this.company.employ(kwame, this.hr);
        this.company.employ(abderemane, this.hr);
        this.company.employ(ranskills, this.hr);
    
        this.company.employ(prince, this.internalAudit);
        this.company.employ(james, this.internalAudit);
        this.company.employ(rufin, this.internalAudit);
    
        raises(function () { that.company.employ(mohammed, that.internalAudit); }, function (e) { return e.name === 'exception.EmployeeException'; }, 'exception.EmployeeException thrown when employing an unqualified person');
    
        equal(this.company.departments.length, 3, 'Department list correctly updated');
        raises(function () { that.company.addDepartment(that.infoSys); }, function (e) { return e.name === 'exception.DepartmentExistException'; }, 'exception.DepartmentExistException thrown when adding an existing department');
        equal(this.company.departments.length, 3, 'Department list does not get incremented when adding existing department');
    
        equal(this.company.getTotalMonthlySalary(), 2076, 'Checking the total monthly salary of the company');
    });
    
  8. Below is the screenshot after refreshing the test page in the browser.
  9. All Tests Successful

    You can drilldown to find the details of a test by clicking on it. E.g., clicking on the Company test shows the details of all the 8 successful tests that were run.

    Company Test Details

Suggested Activities

The best way to learn is to get involved, so I have intentionally left out some features you could implement and test for, like:

  1. Finding all employees who are qualified to go on a mandatory pension based on a simple age criteria.
  2. Ensure the uniqueness of a department’s id generated by the program when no id is passed to the Department constructor
  3. etc

Conclusion

There is so much more to software testing and what I have demonstrated should give you an idea of how testing is done, I would advise you to find some good books and read. For those who want to take it further and make a career out of it, you can find out more from the organization responsible for certifying software testers the world over, International Software Testing Qualifications Board (ISTQB).

Download

Download all the source codes here. All the codes are JSLint complaint.

Create a free website or blog at WordPress.com.