Build a JavaScript Mocking framework in 10 minutes

Intro

Recently I was writing some JavaScript unit tests for the mobile web application I've been working, when I wanted to simply stub out a few methods. From here one might instantly open Google and pick one of the numerous available JavaScript mocking frameworks, however, after thinking for a moment about the dynamic nature of JavaScript, how hard is it really?

These days I like to work with models on the client, all AJAX calls that deal with updating data is usually wrapped up in them, so for the purpose of this exercise let's pretend I have a ProductModel like below:

var ProductModel = {
    fetchAll: function () {
        console.log("AJAX to server");
    }
};

In my test, I don't want to call the server, I just want to return a known object that I can continue to test the logic of my clientside code. So in about 10 minutes I'd knocked out something like this:

(function (exports) {
    exports.Mock = function () {
        var _mock = {
            extend: function (obj) {
                obj.stub = function (name, newBehaviour) {
                    var original = obj[name];
                    obj[name] = function () {
                        return newBehaviour.apply(obj, arguments);
                    };
                    var stubDelegate = obj[name];
                    stubDelegate.original = original;
                    return stubDelegate;
                }
                return obj;
            }
        };
        return _mock;
    } ();
} (window));

Version 1.0 of my very own mocking framework :P So what does it do? Well now when we go to mock the call to ProductModel.fetchAll it would go something like this:

Mock.extend(ProductModel).stub("fetchAll", function(){ console.log("New Behaviour!"); });

So now when I call:

ProductModel.fetchAll();
Mock.extend(ProductModel).stub("fetchAll", function(){ console.log("New Behaviour!"); });
ProductModel.fetchAll();

The output is as expected:

//AJAX to server
//New Behaviour!

The first part "Mock.extend(ProductModel)" extends the ProductModel object with a .stub function that can be used to stub a method on it. The stub function then takes in the name of the desired method to mock and overrides it with the new behaviour. Not too bad for a short time, but, after my test runs, I want to restore the original behaviour of the method. So I simply add a restore method:

(function (exports) {
    exports.Mock = function () {
        var _mock = {
            extend: function (obj) {
                obj.stub = function (name, newBehaviour) {
                    var original = obj[name];
                    obj[name] = function () {
                        return newBehaviour.apply(obj, arguments);
                    };
                    var stubDelegate = obj[name];
                    stubDelegate.original = original;
                    stubDelegate.restore = function () {
                        ///<summary>Removes the test stub and restores the original behaviour</summary>
                        if (!!obj[name].original) {
                            obj[name] = obj[name].original;
                        }
                        return obj;
                    };
                    return stubDelegate;
                };
                return obj;
            }
        };
        return _mock;
    } ();
} (window));

Now we can tell the stub to restore the original behaviour when we're done like this:

ProductModel.fetchAll(); //output: AJAX to server
var stub = Mock.extend(ProductModel).stub("fetchAll", function(){ console.log("New Behaviour!"); });
ProductModel.fetchAll(); //output: New Behaviour!
stub.restore();
ProductModel.fetchAll(); //output: AJAX to server

That's good. The next thing I wanted to check was that the "fetchAll" method didn't get called more than once in my client code, I don't want unnecessary calls back to the server, so I added an "atMost" method. But if I'm going to be counting the number of calls to a method, then likely I can easily add in the post-call assertions too. Thus we're up to Version 2.0 already :D

(function (exports) {
    exports.Mock = function () {
        var _mock = {
            raiseFail: function (message) {
                throw new Error(message);
            },
            extend: function (obj) {
                obj.stub = function (name, newBehaviour) {
                    var atMostLimit = null, callCount = 0;
                    var original = obj[name];
                    obj[name] = function () {
                        ++callCount;
                        if (atMostLimit != null && atMostLimit < callCount) {
                            _mock.raiseFail("'" + name + "' has been called more than its limit of " + atMostLimit);
                        }
                        return newBehaviour.apply(obj, arguments);
                    };
                    var stubDelegate = obj[name];
                    stubDelegate.original = original;
                    stubDelegate.restore = function () {
                        ///<summary>Removes the test stub and restores the original behaviour</summary>
                        if (!!obj[name].original) {
                            obj[name] = obj[name].original;
                        }
                        return obj;
                    };
                    stubDelegate.atMost = function (n) {
                        ///<summary>Sets a limit on the expected calls to this method</summary>
                        atMostLimit = n;
                        return stubDelegate;
                    };
                    stubDelegate.assert = {
                        calledExactly: function (n) {
                            if (callCount != n) {
                                _mock.raiseFail("'" + name + "' was called " + callCount + " time(s) but expecting " + n);
                            }
                            return stubDelegate;
                        },
                        callCount: function () {
                            return callCount;
                        }
                    };
                    return stubDelegate;
                };
                return obj;
            }
        };
        return _mock;
    } ();
} (window));

Which now means if I run the following code again with an atMost of 0, to show a failure:

var stub = Mock.extend(ProductModel)
               .stub("fetchAll", function(){ console.log("New Behaviour!"); })
               .atMost(0);
ProductModel.fetchAll();
//output: Uncaught Error: 'fetchAll' has been called more than its limit of 0

Or I can use the post-action asserts in a similar way:

var stub = Mock.extend(ProductModel)
               .stub("fetchAll", function(){ console.log("New Behaviour!"); });
ProductModel.fetchAll();
stub.assert.calledExactly(0);
//output: Uncaught Error: 'fetchAll' was called 1 time(s) but expected 0

Although I keep using the "Mock.extend" method in the examples, I only need to do this once per object, so the code also could have been represented like this:

Mock.extend(ProductModel);
var stub = ProductModel.stub("fetchAll", function(){ console.log("New Behaviour!"); });
ProductModel.fetchAll();

Lastly if you didn't want to throw unhandled exceptions when one of the call limits was violated you could simply override with your own behaviour:

Mock.raiseFail = function (message) {
    ok(false, message); //integration with QUnit
};

Conclusion

Well in conclusion I guess you can say that mocking with a dynamic language is relatively easy in comparison to statically typed languages like .net. It seemed like an interesting experiment to see what was achievable in a short amount of time, but having said that I guess if you were looking for the Rolls-Royce of features it's still probably better to pick an existing library.

Take it as you will, for simple tasks where you're relying on a framework like QUnit perhaps it's easy enough to push in a few helpers to get the job done without adding too much weight or getting in the way.

JavaScript
Posted by: Brendan Kowitz
Last revised: 25 Sep 2013 00:12AM

Comments

4/3/2012 8:43:30 PM

That's pretty cool.... I might use that in a project I'm working on :)

No new comments are allowed on this post.