TDD/JavaScript: $()
Most modern JavaScript libraries offer some kind of "dollar sign function". As another applied example of test driven development in JavaScript I'll walk you through developing your own dollar sign function.
The following post is a code heavy tutorial which will end up allowing you to do among other things:
var element = $("element");
element.addClassName("super");
console.log(element.hasClassName("super")); // true
The goal of this tutorial is:
- most importantly to learn
- to practice test driven development in JavaScript
- to create a small utility which may come in handy in small projects that don't use a js library
Background
Most libraries offer a function whose name is simply a dollar sign, $(), emulating a language feature. Both
prototype.js and
jQuery pioneered the use of this name.
Usually, the dollar sign function works by retrieving DOM elements by id (like document.getElementById(id)) and then extending the returned elements with convenience methods like addClassName(className), computedStyle(property) and more.
In addition, the function often takes all kinds of inputs. Feed it a DOM element, and it'll return the extended element. Feed it a string and it'll return an extended element whose id matches the string. Feed it an array and it'll return an array.
The spec
Before we hack away, let's try to analyze what it should do:
- Accept different kinds of input:
- a string (return single element)
- a DOM element (return extended element)
- several strings and/or DOM elements (return an array of extended elements)
- an array of strings and/or DOM elements (return an array of extended elements)
- Returned elements should support
hasClassName,addClassNameandremoveClassNamemethods - Adding new methods to extend elements with should be a breeze
Notice the subtle difference in accepting several arguments versus an array of arguments:
$("several", "strings", "here");
$(["an", "array", "of", "strings"])
Tests
We'll use YUI Test with the following test markup:
<div id="sample"></div>
<div id="sample2"></div>
<div id="sample3"></div>
The test case also has a setup method, which is run before each test:
setUp: function() {
this.sample = document.getElementById("sample");
this.sample2 = document.getElementById("sample2");
this.sample3 = document.getElementById("sample3");
}
Retrieving elements
Let's leave the dollar sign realm for now, and focus on the first sub task: retrieving elements. The function is supposed to support all kinds of input. We'll solve that one using a function getElements in the global namespace cj:
testGetWithSingleString: function() {
var assert = YAHOO.util.Assert;
assert.areEqual(this.sample, cj.getElements("sample"));
},
testGetWithArbitraryManyStrings: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements("sample", "sample1");
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
},
testGetWithArrayOfStrings: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements(["sample", "sample1"]);
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
},
testGetWithSingleElement: function() {
var assert = YAHOO.util.Assert;
assert.areEqual(this.sample, cj.getElements(this.sample));
},
testGetWithArbitraryManyElements: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements(this.sample, this.sample1);
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
},
testGetWithArrayOfElements: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements([this.sample, this.sample1]);
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
},
testGetWithArbitraryManyMixed: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements("sample", this.sample1);
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
},
testGetWithMixedArray: function() {
var assert = YAHOO.util.Assert;
var elements = cj.getElements([this.sample, "sample1"]);
assert.areEqual(this.sample, elements[0]);
assert.areEqual(this.sample1, elements[1]);
}
Run tests (they fail massively).
Implementation
The implementation is straight forward:
var cj = {};
cj.getElements = function() {
// Extract array argument
var args = arguments;
args = args.length === 1 && Object.prototype.toString.call(args[0]) == "[object Array]" ? args[0] : args;
// Return array if there were more than one argument
// (or argument was an array)
if (args.length > 1) {
var results = [];
for (var i = 0; i < args.length; i++) {
results.push(cj.getElements(args[i]));
}
return results;
}
// Process single argument
return typeof args[0] === "string" ?
document.getElementById(args[0]) : args[0];
};
Remember that arguments is an array-like structure containing all the incoming arguments, allowing us to loop arbitrary many arguments as an array.
We can now verify that our tests run just fine.
Extending objects
With the element retrieval code in place we can now start worrying about extending elements. I mentioned that adding in new methods for later extension should be simple, so it makes perfect sense to gather these methods in a common namespace. Since we're dealing with DOM elements here, cj.Element seems to make sense.
For now we'll ignore the fact that we want to run the methods on the returned elements. Instead, we'll implement them as standalone functions that accept an element as their first argument.
testHasClassName: function() {
var assert = YAHOO.util.Assert;
assert.isFalse(cj.Element.hasClassName(this.sample, 'test'));
this.sample.className = "holy moly";
assert.isTrue(cj.Element.hasClassName(this.sample, 'holy'));
assert.isFalse(cj.Element.hasClassName(this.sample, 'hol'));
},
testAddClassName: function() {
var assert = YAHOO.util.Assert;
cj.Element.addClassName(this.sample, 'test');
assert.areEqual('test', this.sample.className);
assert.isTrue(cj.Element.hasClassName(this.sample, 'test'));
cj.Element.addClassName(this.sample, 'another');
assert.areEqual('test another', this.sample.className);
},
testRemoveClassName: function() {
var assert = YAHOO.util.Assert;
this.sample.className = "test another";
cj.Element.removeClassName(this.sample, "another");
assert.areEqual("test", this.sample.className);
cj.Element.removeClassName(this.sample, "bla");
assert.areEqual("test", this.sample.className);
cj.Element.removeClassName(this.sample, "test");
assert.areEqual("", this.sample.className);
}
Run tests (which fail).
Implementation
Again, the implementation is fairly straight forward. To add a class name to an element, we'll simply add a space and the new class name, after we've checked it isn't already added. Removing and checking for class names can be done with simple regular expressions.
Having added the cj.Element namespace, it now feels natural to stick the getElements method in there as well. We'll rename it to cj.Element.get() while we're at it:
String.prototype.trim = function() {
return this.replace(/^\s+|\s+$/g, '');
};
var cj = {};
cj.Element = {
get: function() {
// Extract array argument
var args = arguments;
args = args.length === 1 && Object.prototype.toString.call(args[0]) == "[object Array]" ? args[0] : args;
if (args.length > 1) {
var results = [];
for (var i = 0; i < args.length; i++) {
results.push(cj.getElements(args[i]));
}
return results;
}
// Process single argument
return typeof args[0] === "string" ?
document.getElementById(args[0]) : args[0];
},
hasClassName: function(element, className) {
return new RegExp("\\b" + className + "\\b").test(element.className);
},
addClassName: function(element, className) {
if (!cj.Element.hasClassName(element, className)) {
element.className = (element.className + " " + className).trim();
}
},
removeClassName: function(element, className) {
var regex = new RegExp("\\b" + className + "\\b");
element.className = element.className.replace(regex, "").replace(/\s+/, " ");
}
};
Run tests. Oops! Now our extend functions tests are running green, but our element retrieval tests went back to failing. Obviously, we forgot to update our unit tests when we renamed the retrieval function. That's a quick fix: voila!
Extending elements
When implementing the extend functions, I picked up our String.prototype.trim from before. How come I didn't add the extend functions in the same way (ie by adding them to the elements prototypes)? I could have done the following:
HTMLElement.prototype.hasClassName = function(className) {
return new RegExp("\\b" + className + "\\b").test(this.className);
};
...and you'd think it'd work. And it does, atleast in resonable browsers. Unfortunately, this does not include Internet Explorer. IE won't let you add to the prototypes of either Element or HTMLElement. By contrast, this does work in Firefox and Safari. This way you'd get extended elements even if you retrieved them manually through document.getElementById(id). But, since dropping IE support all together usually isn't a solution, we'll have to do some more work.
extend
Let's spec the function that will take an element and extend it:
testExtend: function() {
var assert = YAHOO.util.Assert;
cj.Element.extend(this.sample2);
assert.isNotUndefined(this.sample2.hasClassName);
assert.isFalse(this.sample2.hasClassName("test"));
this.sample2.className = "test";
assert.isTrue(this.sample2.hasClassName("test"));
assert.isUndefined(this.sample2.get, "Get method should not be transferred");
assert.isUndefined(this.sample2.extend, "Extend method should not be transferred");
assert.isNotUndefined(this.sample2.addClassName);
},
testAutoExtend: function() {
var assert = YAHOO.util.Assert;
var sample3 = document.getElementById("sample3");
var sample3b = cj.Element.get("sample3");
assert.areEqual(sample3, sample3b);
assert.isNotUndefined(sample3.hasClassName);
}
That last test checks that an element is extended without going through our cj.Element.get() method (instead it's extended through HTMLElement.prototype). This test will never pass in IE, even when we implement "auto extending". This doesn't mean that our code will work different, it just means that code in IE will be slightly less efficient than in browsers supporting the prototype way.
Implementation
The implementation of the extend method is proabably the most interesting piece of code in this tutorial. I'll show you the code first, then walk you through it:
extend: function(element) {
// Already extended, abort
if (element.hasClassName) {
return element;
}
// Loop all methods in cj.Element
for (var method in cj.Element) { if (cj.Element.hasOwnProperty(method)) {
// Skip get() and extend()
if (method === "get" || method === "extend") {
continue;
}
// Anonymous closure - scope
(function() {
var methodName = method;
// Extend element with method
element[methodName] = function() {
// First argument is element
var args = [element];
// Complete argument list with arguments passed to the method
for (var i = 0; i < arguments.length; i++) {
args.push(arguments[i]);
}
// Run original method in elements context
return cj.Element[methodName].apply(element, args);
};
})();
}}
return element;
}
The first thing this method does is to abort if the element is already extended. This ensures elements are only ever extended once. For browsers that support additions to HTMLElement.prototype, this means that the extend method ain't never run in it's entirety.
The next step is looping all the methods in cj.Element. By filtering through cj.Element.hasOwnProperty(method) we avoid getting methods added to for instance Object.prototype (which is a bad idea anyway). Then we avoid extending the element with the get and extend methods - those aren't needed.
Then some complicated business goes down:
- The generated method becomes a closure. This closure will share local scope with all the other generated methods (ie
element.getClassName()andelement.addClassName()will share scope). This will result in all the methods to actually call the same method, and to avoid this we add another anonymous closure which stores the method to call in a local variable. Gee! - We make use of the fact that
obj[prop] === obj.propin JavaScript (this also goes when properties are methods) - Instead of adding the
cj.Elementmethod directly, we wrap it in an anonymous function that makes sure the element is prepended to the arguments list - The anonymous function sets the context for the
cj.Elementmethod (meaning thatthisinside for instanceaddClassName()will refer to the element itself).
This means we can now add as many methods as we need inside cj.Element, so long as they accept an element as the first argument. The methods will instantly become available on elements retrieved through cj.Element.get(). You can verify this by
running the tests.
Piecing it together
As of now there's no extending happening in cj.Element.get(). That's fairly trivial to fix:
// Change last line in cj.Element.get to
return cj.Element.extend(typeof args[0] === "string" ?
document.getElementById(args[0]) : args[0]);
Another problem is the auto-extending. It's not working at all. Given the current implementation of extend() (which aborts for already extended elements), we can now safely extend HTMLElement.prototype for those browsers that support it, adding a nice performance gain.
Extending the prototype is just like extending an element - we're justing adding methods onto an object:
cj.Element.extend(HTMLElement.prototype);
This solution only has one problem:
var args = [element];
When extending the prototype, the element argument will be the prototype object. However, the actual element is available in this. This can be easily solved:
var args = [this instanceof Element ? this : element];
Final touches
We're basically done, all we need is the function we actually set out to write:
test$: function() {
var assert = YAHOO.util.Assert;
var sample3 = $("sample3");
assert.isNotUndefined(sample3.hasClassName);
}
Which can be implemented thusly:
if (!window.$) {
window.$ = cj.Element.get;
}
Note that we actually take care not overwrite any existing dollar sign functions.
We're done! Run final test suite.
I hope someone finds this tutorial, along with the other three posts on test driven development with JavaScript, interesting. If you have any comments, questions or complaints, please do leave a comment!
Comments are closed