Friday, September 6, 2013

jQuery in TDD is Serious Business (Don't Mock It)

Test-driven development in JavaScript is easy to the point of inducing maniacal laughter -- my office mates don't seem to appreciate this -- because its loosely-typed nature allows you to create mock implementations in-line with writing your tests.

Separately, jQuery brings joy to working with JavaScript in the browser by providing access to the DOM in a way that is natural and consistent.

Unfortunately, these twin marvels don't automatically become one euphoric JavaScript mega-experience when brought together.  Instead, I've found a sort cultural impasse between TDD and jQuery.  TDD forces you to think "what steps will I take to solve a problem," and jQuery tend to make you think "jQuery solves ALL THE THINGS!"  The result is a collection of tests which are highly opinionated about how jQuery is used, instead of being solely opinionated about whether or not the problem is solved.

Let's take a simple example built-out using TDD to demonstrate what I mean by tests that are opinionated about jQuery's implementation and how this most often happens.  If code examples bore you, feel free to skip past this one and go right to my conclusions.

Example: Mocking jQuery in TDD

For our example we'll develop an area of a page where a check box shows or hides a field set when it is clicked.

 
Extra details 
 

Build the Test Fixture

Step one is setting up a QUnit test fixture using jQuery's CDN to run our tests against:

<html>
<head>
<link href="http://code.jquery.com/qunit/qunit-1.12.0.css" rel="stylesheet" type="text/css" />
><script src="http://code.jquery.com/qunit/qunit-1.12.0.js" type="text/javascript"></script>  
<script src="sample.js" type="text/javascript"></script>
<script src="sample.tests.js" type="text/javascript"></script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
</body>
</html> 

In this, sample.tests.js contains our tests driving the code developed in sample.js which will actually perform the work we care about.  In good TDD fashion, we could open this, see catastrophe and determine that our first step is to start writing our actual tests.

Our next step is to create sample.tests.js, and start it with our global variable and QUnit module declarations.

/*global module,test*/
var unit, $;

module('sample', {
 setup: function () {
  'use strict';
 }
});

Here, unit is our unit under test, $ is our mock of jQuery and the comment at the top is a note to JSHint that we will be using the global names module and test from QUnit.

What Do We Want To Do?

Let's outline our test, to capture our intent and help us organize our thoughts:

test('clicking checkbox displays fieldset', function () {
 'use strict';
});

Dissecting that further, what we expect that to mean in terms of testable events is:
  1. The checkbox will be selected
  2. A callback will be bound to the checkbox's click event which will
    1. Select the fieldset
    2. Toggle it to display or hide
Formalizing these expectations as asserts which will pass or fail we get:

var foundSelector,
 foundClickCallback = null,
 toggleCalled = false;

equal(foundSelector, '#uxShowFieldset', 'Checkbox selected by expected ID');
notEqual(foundClickCallback, null, 'Callback set for checkbox click');

// Asserts for callback
equal(foundSelector, '#uxFieldset', 'Fieldset selected by expected ID');
ok(toggleCalled, 'Toggle was called');

Next we'll mock jQuery as we expect it to be used in the implementation and capture the values that we're testing in our asserts:

// After our variable declaration
$ = function (selector) {
 foundSelector = selector;
};
$.click = function (callback) {
 foundClickCallback = callback;
};
$.toggle = function () {
 toggleCalled = true;
};

Lastly we need to perform the actions to test with our mock jQuery injected into it:

// After defining our mock of jQuery
unit = new Sample($);
unit.init();

// Before our second set of asserts
foundClickCallback();

Note: I would ordinarily implement an init method as an IIFE within the constructor. I'm not doing that here, so that we can implement several alternatives for comparison.

Passing the Test

Following proper TDD practices, we will go through the following steps (running and failing the tests between each):
  1. Create the sample.js file
  2. Write the class function, Sample
  3. Write the init function
  4. Implement calls to jQuery to pass our asserts
If our tests are written well, the code that passes our test will work on the page.  However, an astute reader might have already noticed that our tests are not well-written for jQuery.  Consider the following implementation which will pass our test:

$('#uxShowFieldset');
$.click(function () {
 $('#uxFieldset');
});
$.toggle();

Our test assumed that our implementation would follow typical jQuery conventions and chain selectors to subsequent actions.  If we were to do that our test would definitely pass, but my point here is that a developer could write broken code that doesn't pass our test -- and so we must account for that in our test.

Enforcing Good jQuery Usage

To account for that, we need to update our test so that, instead of finding specific selectors and asserting their correctness, it emulates jQuery's ability to chain by returning objects with click and toggle methods that we confirm are called.

Our updated mock becomes:

$ = function (selector) {
 if(selector === expectedCheckboxSelector) {
  return {
   click: function (callback) {
    clickExecuting = true;
    callback();
    clickExecuting = false;
   }
  }
 } else if (clickExecuting && selector === expectedFieldsetSelector) {
  return {
   toggle: function () {
    fieldsetToggled = true;
   }
  }
 }
};

In this mock, we only return an object with testable click and toggle methods for the exact instances when we expect them to be used -- to the point that the click method sets a flag to return the toggle-able object to guarantee that it is being called in response to the click event.  This should force the developer to use jQuery correctly!

We have some new names in our mock, so our updated variable declaration becomes:

var expectedCheckboxSelector = '#uxShowFieldset',
 clickExecuting = false,
 expectedFieldsetSelector = '#uxFieldset',
 fieldsetToggled = false;

A final note on this mock is that one clear benefit is how we only need to assert that the field set was toggled, because that one test implies that everything else in the chain must have succeeded.

So the totality of our asserts becomes:

ok(fieldsetToggled, 'Fieldset was toggled on checkbox click');

Now we're ready to run it against our original implementation and see it fail!  Once you've gotten that out of your system, point the test to a new function so that we can save the old implementation for comparison:

unit.init2();

Following the TDD process again, this test now enforces an implementation that works -- and looks like something you might see in practice:

$('#uxShowFieldset').click(function () {
 $('#uxFieldset').toggle();
});

What this example demonstrates is how natural it is to work through a test and assume jQuery will drive the implementation -- did you question for a second why we were injecting jQuery? -- and that ensure a good jQuery implementation properly you have to emulate its chaining in your mock.

Test Outcomes

The result of having tests that are so aware of jQuery is that their passage depends on the developer writing a specific implementation regardless of whether the outcome is correct or not.

For example, our current test will will fail if we refactor to use jQuery's on binding instead of click,

$('#uxShowFieldset').on('click',function () {
 $('#uxFieldset').toggle();
})

or if we refactor to make our selectors more relative;

$('.fieldsetToggle:checkbox').click(function () {
 $(this).nextAll('fieldset:first').toggle();
})

both of which are examples that improve our code, but will fail our test because the mock is, and has to be, so rigid.  Which leads us to a...

New WebDev Law: A test specific enough to enforce something is done right mocking jQuery, is specific enough to enforce it being done just one way.

Working with tests that mock jQuery will condition a developer to expect tests to fail and require updating after any change in implementation, undermining the core assumption of TDD: the tests are correct.

View Façade

To avoid this, we can write a view façade which serves our class with exactly the functionality it needs -- in its terms -- and hides the jQuery used to provide it.

Revisiting our example, let's write a new test with this in mind.

We'll start with our first instincts and restate the asserts from the first test in the example, but in a non-jQuery way (i.e. no selectors):


var checkbox = {},
 foundCheckbox,
 foundClickCallback = null, 
 fieldset = {},
 foundFieldset,
 toggleCalled;

equal(foundCheckbox, checkbox, 'Correct checkbox found for binding click event');
notEqual(foundClickCallback, null, 'Callback for click event provided');

// Asserts for callback
equal(foundFieldset, fieldset, 'Correct fieldset found for binding click event');
ok(toggleCalled, 'Fieldset was toggled');

Now we'll mock out what we expect this new view façade to provide by appending a new facade to the variable declaration:

facade = {
 getCheckbox: function () {
  return checkbox;
 },
 bindClick: function (element, callback) {
  foundCheckbox = element;
  foundClickCallback = callback;
 },
 getFieldset: function () {
  return fieldset;
 },
 toggleElement: function (element) {
  foundFieldset = element;
  toggleCalled = true;
 }
};

For this example, I've chosen to keep it simple by directly translating what our asserts expect into the methods that façade provides; this approach isn't required, what matters is that the façade serves the view terms that make sense within its context.

Note: the fact that toggleCalled is true is implied by foundFieldset being equal to fieldset, so we can refactor to remove toggleCalled.

The final step to building our test is to perform the actions to test with our mock façade:

// After defining our mock of the view facade
unit = new Sample(facade);
unit.init3();

// Before our second set of asserts
foundClickCallback();

Once we work through our implementation, it should look something like this (if the façade is called view locally):

view.bindClick(
 view.getCheckbox(),
 function () {
  view.toggleElement(view.getFieldset());
 }
);

By completely hiding jQuery from our Sample class, any changes to how the implementation within the view façade uses jQuery won't require an update to our tests.

jQuery is a DAL

This sort of encapsulation should seem familar to any developer who's worked in layered applications, because this is essentially the same sort of task-based abstraction applied by a business layer over a data layer.  This isn't an accident, because jQuery, as the intermediary for reading from and writing to the DOM, is a data layer (among other things) -- we just don't typically see it that way, because it sits at the very opposite end of the stack from the traditional data layer.

That Solves Everything (Except the Original Problem)

Now we can avoid complicated jQuery mocks that make the tests for our view code fragile.  The only cost is an additional small layer that we have to implement ... which works directly with jQuery.

Won't we have the exact same problem when we write tests for the view façade?  

I'll post how to test the view façade while avoiding the same pitfalls in the next installment.

As described in my later post, My "View Adapter" Was Just a Façade, I updated this post to refer to a "view façade" instead of a "view adapter" as it had originally been written.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.