A WinJS SpecRunner: Automating Script Tag Insertion For Unit Tests

Writing unit tests with Mocha or Jasmine is generally pretty easy. Once you have a test runner set up, it’s not much different than any other JavaScript environment, really. But the trick to this is getting a test runner set up.

Getting A Test Project Set Up

Christopher Bennage has already blogged about the basic set up that we put in to our project. The gist of it is that we have to manually add a linked file from our production app in to our test runner app, every time we need to write tests for that production file. We’ve also had to manually add the individual file reference as a <script> tag, along with a <script> tag for the tests for that file, in side of our “default.html” file. This tells the project to load and run the script and its associated tests. 

The result of all this manual <script> tag maintenance was painful at best, and nightmarish most of the time. Here’s an incomplete screenshot of all the files that we had to manually add as <script> tags. Note that I said incomplete screenshot…

Screen Shot 2012 08 21 at 8 54 35 AM

Reducing The Script Tag Nightmare

I got tired of this, as you can imagine, so I fixed it. Yesterday I introduced a bit of code that allowed me to reduce the number of <script> tags from what you see in the screenshot above, down to this:

Screen Shot 2012 08 21 at 8 59 33 AM

That’s much better! And the best part is, I don’t have to touch this file again. I can add specs to my app, and link production files in to the test runner all day long, and I never need to change this file. 

The key to the reduction of <script> tags is that last file I included: specRunner.js. This file takes advantage of the WinRT/WinJS runtime environment to examine the local file system that the code is running from, use a few very simple conventions along with a bit of configuration to find the files it needs, and dynamically generate the needed  <script> tags for me, inserting them in to the DOM.

Configuring The SpecRunner

In the “default.js” page control, I have this code:

	function runSpecs() {
		// configure the spec runner
		var specRunner = new Hilo.SpecRunner({
			src: "Hilo",
			specs: "specs",
			helpers: "specs/Helpers"
		});

		// Handle any errors in the execution that
		// were not part of a failing test
		specRunner.addEventListener("error", function (args) {
			document.querySelector("body").innerText = args.detail;
		});

		// run the specs
		specRunner.run();
	}

// ===============================================================================
//  Microsoft patterns & practices
//  Hilo JS Guidance
// ===============================================================================
//  Copyright © Microsoft Corporation.  All rights reserved.
//  This code released under the terms of the 
//  Microsoft patterns & practices license (http://hilojs.codeplex.com/license)
// ===============================================================================

Here you can see the few bits of configuration that I’m passing in – the folder that contains the source files, the spec files, and a helpers folder. This helpers folder is used to load up any helper scripts – extra libraries, common functions, and anything else you need that isn’t directly a test. Just drop a .js file in this folder and it will be included in the test runner.

I’ve also included an “error” event that gets dispatched from the spec runner object, as you can see. This uses the eventMixin that I’ve blogged about before to dispatch events. The purpose of this trigger is to let you know when the test runner configuration has failed. It does not report errors from Mocha or Jasmine or anything like that, only from the spec runner set up.

Coding The SpecRunner

My implementation of the spec runner is fairly simple, but it does do quite a bit. The heavy use of WinJS promises necessitates a lot of callback functions which I like to organize in to a series of steps to perform.

(function (global) {
	"use strict";

	// SpecRunner Constructor
	// ----------------------

	// This objects searches through the project folder, the specs folder,
	// and the specs helpers folder to find all available JavaScript files.
	// It inserts a script tag in to the DOM for each file it finds.
	//
	// The `options` parameter allows three options to be passed in:
	// * `specs`: the folder to search for `*spec.js` files
	// * `helpers`: a folder that contains helper objects and methods for the specs
	// * `src`: the folder that contains all fo the source files that will be tested

	function SpecRunner(options) {
		this.specFolder = options.specs || "specs";
		this.helperFolder = options.helpers || "specs/helpers";
		this.srcFolder = options.src || "src";
	}

	// SpecRunner Methods
	// ------------------

	var specRunnerMethods = {
		configureMocha: function(){
			global.expect = chai.expect;
			global.mocha.setup("bdd");
		},

		run: function () {
			this.appFolder = Windows.ApplicationModel.Package.current.installedLocation;
			this.configureMocha();

			this.injectHelpers()
				.then(this.injectPageControls.bind(this))
				.then(this.injectSpecList.bind(this))
				.then(this.startTestHarness.bind(this))
				.done(null, this.triggerError.bind(this));
		},

		triggerError: function (error) {
			this.dispatchEvent("error", error);
		},

		startTestHarness: function () {
			global.mocha.run();
		},

		injectPageControls: function () {
			return this.getFolder(this.srcFolder)
				.then(this.getJSFileNames.bind(this))
				.then(this.buildScriptTags.bind(this))
				.then(this.addScriptsToBody.bind(this));
		},

		injectHelpers: function () {
			return this.getFolder(this.helperFolder)
				.then(this.getJSFileNames.bind(this))
				.then(this.buildScriptTags.bind(this))
				.then(this.addScriptsToBody.bind(this));
		},

		injectSpecList: function () {
			return this.getFolder(this.specFolder)
				.then(this.getSpecFileNames.bind(this))
				.then(this.buildScriptTags.bind(this))
				.then(this.addScriptsToBody.bind(this));
		},

		getFolder: function (folderName, parentFolder) {
			parentFolder = parentFolder || this.appFolder;

			var names = folderName.split("/");
			var name = names.shift();

			var folder = parentFolder.getFolderAsync(name);
			if (names.length === 0) {

				// Found the final folder. Return it.
				return folder;

			} else {

				// More folders to find. Recursively load them.
				var that = this;
				return folder.then(function (newParent) {
					return that.getFolder(names.join("/"), newParent);
				});

			}
		},

		getJSFileNames: function (folder) {
			var nameTest = /.*js$/;
			return this._buildFileListFromRegex(nameTest, folder);
		},

		getSpecFileNames: function (folder) {
			var specTest = /[Ss][Pp][Ee][Cc].*js/;
			return this._buildFileListFromRegex(specTest, folder);
		},

		buildScriptTags: function (fileList) {
			var appPath = this.appFolder.path;

			var specList = fileList.map(function (file) {
				var filePath = file.path.replace(appPath, "");
				var scriptEl = document.createElement("script");
				scriptEl.setAttribute("src", filePath);

				return scriptEl;
			});

			return WinJS.Promise.as(specList);
		},

		addScriptsToBody: function (scriptTags) {
			var body = document.querySelector("body");
			scriptTags.forEach(function (tag) {
				body.appendChild(tag);
			});

			return WinJS.Promise.as(true);
		},

		_buildFileListFromRegex: function(regEx, folder){
			var fileQuery = folder.getFilesAsync(Windows.Storage.Search.CommonFileQuery.orderByName);

			return fileQuery.then(function (files) {
				var fileList = files.filter(function (file) {
					return regEx.test(file.name);
				});
				return WinJS.Promise.as(fileList);
			});
		}
	};

	// Public API
	// ----------

	WinJS.Namespace.define("Hilo", {
		SpecRunner: WinJS.Class.mix(SpecRunner, specRunnerMethods, WinJS.Utilities.eventMixin)
	});
})(this);

// ===============================================================================
//  Microsoft patterns & practices
//  Hilo JS Guidance
// ===============================================================================
//  Copyright © Microsoft Corporation.  All rights reserved.
//  This code released under the terms of the 
//  Microsoft patterns & practices license (http://hilojs.codeplex.com/license)
// ===============================================================================

You can the high level list of steps in the “run” method, with each of those primary steps being a breakdown of other steps to takes. I’ve also hard coded my version of the spec runner to configure and run Mocha tests. It would not be difficult to change this to run Jasmine tests, or to abstract this a little bit more and make the test runner configurable with callback functions or other means.

Follow The Code; We’re Not Done Yet

I love this solution. It was easy to write and it works very well for our project. But we’re not done solving the unit testing problem, yet. I still have to manually link the files from the production app in to the test app. We’re thinking through solutions to that problem as well, but it’s proving to be much more difficult than we had hoped.

Also, if you’re interested in following along as we make project through this project (through the end of September, basically), you can get the code from our CodePlex repository. Be sure to check out the discussion list as well. There’s a lot of great discussion going on, and some very interesting insights in to the thought process of our project structure and architecture.


Post Footer automatically generated by Add Post Footer Plugin for wordpress.

About Derick Bailey

Derick Bailey is an entrepreneur, problem solver (and creator? :P ), software developer, screecaster, writer, blogger, speaker and technology leader in central Texas (north of Austin). He runs SignalLeaf.com - the amazingly awesome podcast audio hosting service that everyone should be using, and WatchMeCode.net where he throws down the JavaScript gauntlets to get you up to speed. He has been a professional software developer since the late 90's, and has been writing code since the late 80's. Find me on twitter: @derickbailey, @mutedsolutions, @backbonejsclass Find me on the web: SignalLeaf, WatchMeCode, Kendo UI blog, MarionetteJS, My Github profile, On Google+.
This entry was posted in Jasmine, Javascript, MochaJS, Productivity, Test Automation, Unit Testing, WinJS. Bookmark the permalink. Follow any comments here with the RSS feed for this post.
  • http://twitter.com/mohamedmansour Mohamed Mansour

    Thanks for this post, nicely written!

  • kamranayub

    I’ve been working with WinJS for a bit and I feel the same way I felt about working with Node.js: you need to write tests or you’ll go insane.

    What made you guys decide to do a separate project (besides organization)? In WinRT, I assume there’s a way to see if you’re inside a debugging context (or local context, i.e. not installed “for real”). If so, why not add an icon to the app bar and run your tests within the app, so you don’t need to manually link stuff?

    Off the top of my head, the only thing that jumps to mind is that the file size and app size will increase because everything’s together, rather than if you separate.

  • kamranayub

    Have you guys made headway into this more? I’m starting to write tests for my app and am using a test runner like this… the manual linking is annoying.

    Since apps you’re developing/debugging can access the network, could you create a Web API proxy to redirect requests to the local file system? i.e. http://localhost:999/js/foo.js is translated to the appropriate file path.

    Maybe that’s overkill; maybe you could use Node to help out or something.