Archive for March, 2011

Testing jQuery plugins with Node.js and Jasmine

I'm easing into Node.js and I wanted to share some of the stuff I'm learning. One of my focal points lately has been javascript testing. I finally got a test suite in place for my masked input plugin and I was happy with the results. I used Jasmine and the additional Jasmine-species BDD grammar. For me the next step is to be able to get a quick feedback loop on my tests without going to a browser.

So, let's start with a simple jQuery plugin.

// placeholder.js
(function($) {
	$.fn.placeholder=function(description){
		return this.each(function(){
			var input=$(this);
			input
				.bind("blur.placeholder",function(){
					input.val(input.val()||description);
				})
				.bind("focus.placeholder",function(){
					if(input.val()==description)
						input.val('');
				})
				.trigger("blur.placeholder");
		});
	};
})(jQuery);

This is a plugin that a million people have written before in various forms. This plugin provides a description of the input inside as the value of the input until it has focus. When focused, it disappears. If the user types in their own value and leaves (blur) the input, their text stays. If they type nothing, then we put the description back in.

With that knowledge, I stabbed together a few jasmine specifications in a subdirectory named "specs".

//empty.spec.js
var input=jQuery("<input />").appendTo("body").placeholder("foo");

describe('No value',function(){
	describe('when calling placeholder plugin', function () {
		it('should show placeholder value', function () {
			expect(input.val()).toEqual("foo");
		});
	});

	describe('when focusing input without user value', function () {
		runs(function(){input.focus();})
		it('should be empty', function () {
			expect(input.val()).toEqual("");
		});
	});

	describe('when leaving input without user value', function () {
		runs(function(){input.focus().blur();})
		it('should show placeholder value', function () {
			expect(input.val()).toEqual("foo");
		});
	});
});

and

//value.spec.js
var input=jQuery("<input />").appendTo("body").val("bacon").placeholder("foo");

describe('User supplied value',function(){
	describe('when calling placeholder plugin', function () {
		it("should have the user's value", function () {
			expect(input.val()).toEqual("bacon");
		});
	});

	describe('when focusing input with user value', function () {
		runs(function(){input.focus();})
		it("should contain the user's value", function () {
			expect(input.val()).toEqual("bacon");
		});
	});

	describe('when leaving input with user value', function () {
		runs(function(){input.focus().blur();})
		it("should contain the user's value", function () {
			expect(input.val()).toEqual("bacon");
		});
	});
});

Now, these may not be the well written tests, but they get the job done. If you've never seen this before, I highly recommend heading over to the jasmine website and looking through the docs.

Alright, we have a plugin and we have specifications. Let's write a Node.js script to wrap this up. First I'll post the script and then break it down afterwards.

//runspecs.js

//fake browser window
global.window = require("jsdom")
		.jsdom()
		.createWindow();
global.jQuery = require("jquery");

//Test framework
var jasmine=require('jasmine-node');
for(var key in jasmine) {
  global[key] = jasmine[key];
}

//What we're testing
require("./placeholder.js")

jasmine.executeSpecsInFolder(__dirname + '/specs', function(runner, log){
    process.exit(runner.results().failedCount?1:0);
}, true, true);

The first thing I want to draw attention to are all of the 'require' statements in the script. These are references to node packages I've installed (except the placeholder.js one, that's the thing in the first code snippet). In order to run this script, you'll need to use the node package manager(npm) to install them on your local machine.

The first statement uses jsdom to create a mock browser window. jQuery needs this in order to do it's magic. What you'll notice though is that I'm assigning this to a property off of the global variable. By default variables you declare are only available to the script in which they are declared. By tacking this on to the global object, node will provide this window variable to all other modules or scripts we load up.

Next we load up jQuery and assign it to the global scope too. I'm doing this because my plugins reference jQuery as a global. Here's the template I've been using when writing plugins:

(function($){
   //good stuff goes here
})(jQuery)

After that, we have a few statements to load up the jasmine test framework. The for loop pulls out all of the test syntax (describe,it,runs,beforeEach,afterEach,etc) hanging off the jasmine variable and makes it a global as well.

Now things are getting good. We're finally ready to include the thing we're actually testing. This require statement will reference the path to your plugin script relative to the directory of the node script.

The final statement calls a method to invoke the jasmine specs. Notice that we're not supplying any specific file names here. Instead, we just point it to a directory which contains the specs. It does the rest. When it's done, we see if there were any failed tests and then supply an appropriate exit code.

To get this whole deal started, we simply type node runspecs.js at our terminal prompt and see how we did. When I run it, this is what I see:

$ node runspecs.js
(node) process.compile should not be used. Use require('vm').runInThisContext instead.
Started
......

Spec No value when calling placeholder plugin
Spec No value when focusing input without user value
Spec No value when leaving input without user value
Spec No value
Spec User supplied value when calling placeholder plugin
Spec User supplied value when focusing input with user value
Spec User supplied value when leaving input with user value
Spec User supplied value
Finished in 0.003 seconds
8 tests, 6 assertions, 0 failures

Success!

Masked Input Plugin 1.3

There is now a new version of my Masked Input Plugin for jQuery.  This is primarily a bugfix release. The biggest deal for me with this release has been the addition of a test suite.  In order to do so, I ended up writing a keystroke simulator which I call keymasher. It's not a perfect way to test, but it's better than nothing.

As a product of adding the tests, I found a few inconsistent issues with the delete and backspace handling which are now resolved. I was also able to take advantage of some event normalization that jQuery provides which didn't exist when I first wrote this plugin.  That plus a switch to UglifyJS has resulted in a smaller compressed file size (3.26KB for v1.3 vs 3.46KB for v1.2.2).

Bugfixes:

  • Fixed completed callback bug.
  • Fixed IE bug requiring charAt() instead of array notation to access char within string.
  • Fixed delete key handling with cursor at literal character.
  • Fixed infinite focus loop bug with multiple masked inputs on a page.
  • Fixed raw value returning mask placeholders when input empty.

Enhancements:

  • Now gracefully handle it when mask() gets called multiple times by calling unmask() on behalf of the user.

As always, if you have encounter any issues, please feel free to report them over on my github project. I'll do my best to  fix them in a timely manner. Also, if this plugin has helped you out, feel free to throw a few bucks my way by clicking the donate button at the top of the page.