Writing Testable Frontend Javascript Part 2 - Refactor away anti-patterns

This is the second of a two part introduction on how to write testable Javascript UI code. The first article, Anti-Patterns and their fixes, uses a sample application to introduce several common, avoidable, and test-inhibiting anti-patterns. Why these common practices are anti-patterns and how to fix them are explained. This article continues by refactoring the original application to be easier to read, easier to reuse, and easier to test. Once refactoring is complete, testing begins: a test harness is created, an XHR mock developed, and finally, a full unit test suite is added.

Use best practices to write testable UI code

The first article, Anti-patterns and their fixes, lists several best practices to make UI code testable:

  • Externalize all Javascript
  • Provide a public interface
  • Use instantiable objects
  • Flatten the pyramid of doom
  • Separate DOM event handlers from the action it performs
  • Notify interested parties when an asynchronous function completes
  • Clean up after your objects
  • Mock in XHR requests
  • Separate application initialization into its own module

Using this list as a guide, the original application is completely refactored with the goal of adding tests.

Start with the HTML - externalize all scripts

The original inline Javascript has been externalized and placed into two files: authentication-form.js and start.js. The bulk of the original logic is contained within the module authentication-form.js. The application is initialized in start.js.

Excerpt from index.html

  
  
  
  
  A Testable Authentication Form
  
  
  ...

  
  
  
  
  
  

Modules encapsulate logic with the option of a public interface

AuthenticationForm is a publicly accessible module that neatly encapsulates the majority of the original logic. AuthenticationForm provides a public interface through which functionality can be tested.

A public interface - excerpt from authentication-form.js

// The Module pattern is used to encapsulate logic. AuthenticationForm is the 
// public interface.
var AuthenticationForm = (function() {
  "use strict";
  ...

Use instantiable objects

The original form was not instantiable, its code was meant only to be run once. Effective unit testing was nearly impossible. The refactored AuthenticationForm is a prototype object; new instances are created using Object.create.

Instantiable object - excerpt from authentication-form.js

var AuthenticationForm = (function() {
  "use strict";

  // Module is the prototype object that is assigned to 
  // AuthenticationForm. New instances of AuthenticationForm
  // are created using:
  //
  //    var authForm = Object.create(AuthenticationForm)
  //
  var Module = {
    init: function(options) {
    ...
  };

  return Module;
  ...
}());

Flatten the pyramid

The refactored AuthenticationForm extracts logic from the original pyramid of doom into four publicly accessible functions. Functions are provided for object initialization and destruction, a further two are available in its testing interface.

Flattened pyramid - excerpt from authentication-form.js

  ...
  var Module = {
    init: ...
    teardown: ...

    // BEGIN TESTING API
    submitForm: submitForm,
    checkAuthentication: checkAuthentication
    // END TESTING API
  };
  ...

Separate DOM event handlers from the action it performs

Separate DOM event handlers from the action it performs to help make action logic reusable and testable.

DOM events are separated from their actions - excerpt from authentication-form.js

    ...
    init: function(options) {
      ...
      // A little bit of setup is needed for teardown. This will be
      // explained shortly.
      this.submitHandler = onFormSubmit.bind(this);
      $("#authentication_form").on("submit", this.submitHandler);
    },
    ...
  };

  ...

  // Separate the submit handler from the actual action. This allows 
  // onFormSubmit takes care of the event then calls submitForm like any
  // other function would.
  function onFormSubmit(event) {
    event.preventDefault();

    submitForm.call(this);
  }

  // submitForm to be called programatically without worrying about 
  // handling the event.
  function submitForm(done) {
    ...
  }

Use callbacks (or some other notification mechanism) in asynchronous functions

The two functions in AuthenticationForm's testing interface, submitForm and checkAuthentication, are asynchronous. Both accept an optional callback to invoke when all processing is complete.

Asynchronous functions with callbacks - excerpt from authentication-form.js

  };
  ...

  // checkAuthentication is asynchronous but the unit tests need to
  // perform their checks after all actions are complete. "done" is an optional
  // callback that is called once all other actions complete.
  function submitForm(done) {
    ...
  }

  // checkAuthentication makes use of the ajax mock for unit testing.
  function checkAuthentication(username, password, done) {
    ...
  }
  ...

Clean up after your objects

Unit tests should run in isolation of each other. Any state, including attached DOM event handlers, must be reset between tests.

Attached DOM event handlers are removed - excerpt from authentication-form.js

   ...
   init: function(options) {
      ...

      // If unit tests are run multiple times, it is important to be able to
      // detach events so that one test run does not interfere with another.
      this.submitHandler = onFormSubmit.bind(this);
      $("#authentication_form").on("submit", this.submitHandler);
    },

    teardown: function() {
      // detach event handlers so that subsequent test runs do not interfere
      // with each other.
      $("#authentication_form").off("submit", this.submitHandler);
    },
    ...

Separate application initialization into its own module

start.js is a self-invoking function that is run after all other Javascript has loaded. Since our application is very simple, only a small amount of initialization is needed - a single AuthenticationForm instance is created and initialized.

start.js

(function() {
  "use strict";

  var authenticationForm = Object.create(AuthenticationForm);
  authenticationForm.init();
}());

At this point, the entire original application has been refactored and re-implemented. Users should see zero change in functionality, modifications are purely under the hood.

What about the tests?

Even though our code is now testable, an article on unit testing is incomplete without actually writing some tests! Several high quality unit test frameworks exist, QUnit is used for this example.

First, a test harness is needed. A test harness is composed of a mock DOM and Javascript code. A mock DOM consists of elements that are used during testing, usually things like form elements or elements that you check the visibility of. To avoid cross test pollution, the mock DOM is reset after every unit test. QUnit expects the mock DOM to be in the #qunit-fixture element.

Javascript code includes a unit test runner, the code being tested, mock dependencies, and the tests themselves.

Test Harness - excerpt from tests/index.html

  ...
  

Authentication Form Test Suite

...
...
...

Write the XHR mock

XHR requests introduce a dependency on the back end; requests from the front end must be answered or else the app will sit idle. Testing with real XHR requests means the front end cannot be tested until the back end is ready, a serious hindrance to parallel development. Instead of making XHR requests, a mock can be used. Mocks are stand-in objects that can be precisely controlled for testing. A mock must implement all the functions that are used by its consumer. Luckily, the XHR mock (named AjaxMock) only has to implement a small portion of the overall jQuery.ajax. This single mocked function provides the ability to synthesize every possible back end response. Several extra public functions have been added to facilitate unit testing.

AjaxMock interface

AjaxMock = (function() {
  ...

  /*
   * AjaxMock mimicks portions of the $.ajax functionality.
   * See http://api.jquery.com/jQuery.ajax/
   */
  var AjaxMock = {
    // The only jQuery function that is needed by the consumer
    ajax: function(options) {
       ...
    },

    // What follows are non standard functions used for testing.
    setSuccess: ...

    setError: ...

    getLastType: ...

    getLastURL: ...

    getLastData: ...
  };

  return AjaxMock;

}());

Finish with some tests!

Now that both the harness and the XHR mock are ready, we can write some tests! The test suite consists of six distinct tests. Every test instantiates a fresh AuthenticationForm object and XHR mock. The XHR mock makes it possible to write tests for every possible back end response.

(function() {
  "use strict";

  var ajaxMock,
      authenticationForm;

  module("testable-authentication-form", {
    setup: function() {
      // create a mock XHR object to inject into the authenticationForm for
      // testing.
      ajaxMock = Object.create(AjaxMock);
      authenticationForm = Object.create(AuthenticationForm);
      authenticationForm.init({
        // Inject the ajax mock for unit testing.
        ajax: ajaxMock.ajax.bind(ajaxMock)
      });
    },
    teardown: function() {
      // tear down the authenticationForm so that subsequent test runs do not
      // interfere with each other.
      authenticationForm.teardown();
      authenticationForm = null;
    }
  });

  asyncTest("submitForm with valid username and password", function() {
    $("#username").val("testuser");
    $("#password").val("password");

    ajaxMock.setSuccess({
      success: true,
      username: "testuser",
      userid: "userid"
    });

    authenticationForm.submitForm(function(error) {
      equal(error, null);

      ok($("#authentication_success").is(":visible"));

      start();
    });
  });

  ...
}());

Summary

It took a while, but we got to where we want to be. Our code is easy to read, easy to re-use, and has a full test suite.

Writing testable code is often a challenge, but the basics are easy once you are used to it. Before writing a single line of code, start with the question "how am I going to test this?" This simple question will end up saving countless hours and give you confidence when refactoring or adding new features.

All code is available on Github at https://github.com/shane-tomlinson/shanetomlinson.com/tree/master/2013-jan-writing-testable-ui-javascript.

If you have any questions, send a shout.

Final product

index.html





  A Testable Authentication Form


  

authentication-form.js

// The Module pattern is used to encapsulate logic. AuthenticationForm is the 
// public interface.
var AuthenticationForm = (function() {
  "use strict";

  var Module = {
    init: function(options) {
      options = options || {};

      // Use an injected request function for testing, use jQuery's xhr
      // function as a default.
      this.ajax = options.ajax || $.ajax;

      // If unit tests are run multiple times, it is important to be able to
      // detach events so that one test run does not interfere with another.
      this.submitHandler = onFormSubmit.bind(this);
      $("#authentication_form").on("submit", this.submitHandler);
    },

    teardown: function() {
      // detach event handlers so that subsequent test runs do not interfere
      // with each other.
      $("#authentication_form").off("submit", this.submitHandler);
    },

    // BEGIN TESTING API
    // A build script could strip this out to save bytes.
    submitForm: submitForm,
    checkAuthentication: checkAuthentication
    // END TESTING API
  };

  return Module;

  // Separate the submit handler from the actual action. This allows 
  // submitForm to be called programatically without worrying about 
  // handling the event.
  function onFormSubmit(event) {
    event.preventDefault();

    submitForm.call(this);
  }

  // checkAuthentication is asynchronous but the unit tests need to
  // perform their checks after all actions are complete. "done" is an 
  // optional callback that is called once all other actions complete.
  function submitForm(done) {
    var username = $("#username").val();
    var password = $("#password").val();

    if (username && password) {
      checkAuthentication.call(this, username, password, 
          function(error, user) {
        if (error) {
          $("#authentication_error").show();
        } else {
          updateAuthenticationStatus(user);
        }

        // surface any errors so tests can be done.
        done && done(error);
      });
    }
    else {
      $("#username_password_required").show();

      // pass back an error message that can be used for testing.
      done && done("username_password_required");
    }
  }

  // checkAuthentication makes use of the ajax mock for unit testing.
  function checkAuthentication(username, password, done) {
    this.ajax({
      type: "POST",
      url: "/authenticate_user",
      data: {
        username: username,
        password: password
      },
      success: function(resp) {
        var user = null;
        if (resp.success) {
          user = {
            username: resp.username,
            userid: resp.userid
          };
        }

        done && done(null, user);
      },
      error: function(jqXHR, textStatus, errorThrown) {
        done && done(errorThrown);
      }
    });
  }

  function updateAuthenticationStatus(user) {
    if (user) {
      $("#authentication_success").show();
    }
    else {
      $("#authentication_failure").show();
    }
  }
}());

start.js

(function() {
  "use strict";

  var authenticationForm = Object.create(AuthenticationForm);
  authenticationForm.init();
}());

tests/index.html




  
  


  

Authentication Form Test Suite

    tests/ajax-mock.js

    AjaxMock = (function() {
      "use strict";
    
      /*
       * The AjaxMock object type is a controllable XHR module used for unit
       * testing. It is injected into the AuthenticationForm so that real XHR
       * requests are not made. Instead, the mock can be controlled to return
       * expected values.
       *
       * AjaxMock mimicks the portions of the $.ajax functionality.
       * See http://api.jquery.com/jQuery.ajax/
       */
      var AjaxMock = {
        // The only jQuery function used for ajax requests
        ajax: function(options) {
          this.type = options.type;
          this.url = options.url;
          this.data = options.data;
    
          if ("successValue" in this) {
            // Neither our code nor our tests make use of jqXHR or textStatus
            if (options.success) options.success(this.successValue);
          }
          else if ("errorValue" in this) {
            // Neither our code nor our tests make use of jqXHR or textStatus
            if (options.error) options.error(null, 500, this.errorValue);
          }
          else {
            throw new Error("setSuccess or setError must be called before ajax");
          }
        },
    
        // What follows are non standard functions used for testing.
        setSuccess: function(successValue) {
          this.successValue = successValue;
        },
    
        setError: function(errorValue) {
          this.errorValue = errorValue;
        },
    
        getLastType: function() {
          return this.type;
        },
    
        getLastURL: function() {
          return this.url;
        },
    
        getLastData: function() {
          return this.data;
        }
      };
    
      return AjaxMock;
    
    }());
    

    tests/authentication-form.js

    (function() {
      "use strict";
    
      var ajaxMock,
          authenticationForm;
    
      module("testable-authentication-form", {
        setup: function() {
          // create a mock XHR object to inject into the authenticationForm for
          // testing.
          ajaxMock = Object.create(AjaxMock);
          authenticationForm = Object.create(AuthenticationForm);
          authenticationForm.init({
            // Inject the ajax mock for unit testing.
            ajax: ajaxMock.ajax.bind(ajaxMock)
          });
        },
        teardown: function() {
          // tear down the authenticationForm so that subsequent test runs do not
          // interfere with each other.
          authenticationForm.teardown();
          authenticationForm = null;
        }
      });
    
      asyncTest("submitForm with valid username and password", function() {
        $("#username").val("testuser");
        $("#password").val("password");
    
        ajaxMock.setSuccess({
          success: true,
          username: "testuser",
          userid: "userid"
        });
    
        authenticationForm.submitForm(function(error) {
          equal(error, null);
    
          ok($("#authentication_success").is(":visible"));
    
          start();
        });
      });
    
      asyncTest("submitForm with invalid username and password", function() {
        $("#username").val("testuser");
        $("#password").val("invalidpassword");
    
        ajaxMock.setSuccess({
          success: false
        });
    
        authenticationForm.submitForm(function(error) {
          equal(error, null);
    
          ok($("#authentication_failure").is(":visible"));
    
          start();
        });
      });
    
      asyncTest("submitForm with missing username and password", function() {
        $("#username").val("");
        $("#password").val("");
    
        authenticationForm.submitForm(function(error) {
          equal(error, "username_password_required");
    
          ok($("#username_password_required").is(":visible"));
    
          start();
        });
      });
    
      asyncTest("submitForm with XHR error", function() {
        $("#username").val("testuser");
        $("#password").val("password");
    
        ajaxMock.setError("could not complete");
    
        authenticationForm.submitForm(function(error) {
          equal(error, "could not complete");
    
          ok($("#authentication_error").is(":visible"));
    
          start();
        });
      });
    
      asyncTest("checkAuthentication with valid user", function() {
        ajaxMock.setSuccess({
          success: true,
          username: "testuser",
          userid: "userid"
        });
    
        authenticationForm.checkAuthentication("testuser", "password", 
             function(error, user) {
          equal(error, null);
    
          equal(ajaxMock.getLastType(), "POST");
          equal(ajaxMock.getLastURL(), "/authenticate_user");
    
          var data = ajaxMock.getLastData();
          equal(data.username, "testuser");
          equal(data.password, "password");
    
          equal(user.username, "testuser");
          equal(user.userid, "userid");
    
          start();
        });
      });
    
      asyncTest("checkAuthentication with missing XHR error", function() {
        ajaxMock.setError("could not complete");
        authenticationForm.checkAuthentication("testuser", "password", 
            function(error) {
          equal(error, "could not complete");
    
          start();
        });
      });
    
    }());