Skip to content

Instantly share code, notes, and snippets.

@NickHeiner
Created April 2, 2013 00:29
Show Gist options
  • Save NickHeiner/5288977 to your computer and use it in GitHub Desktop.
Save NickHeiner/5288977 to your computer and use it in GitHub Desktop.
http mocking tool

zelda

Installation

# This won't work if you're not within the Opower firewall - all you need is zelda.js and lodash, which you can get with `bower install lodash`
$ bower install --save [email protected]:nick-heiner/zelda.git

Then, include <your bower install dir>/zelda/zelda.js and <your bower install dir>/lodash/lodash.js in your html or tests. <your bower install dir> defaults to components/.

Usage

Setting up mocks

Place the following in a js file that is included by your project:

fixtures.js:

    // change 'my-module-name' to be the name of your module
    angular.module('my-module-name')
    
      // The service you're mocking needs to be defined first
      // If you don't have a real API yet, define a stub as follows:
      .value('hammerTime', {})
      
      .run(function($zelda) {
        
        // wrap the angular service `hammerTime` with our mocks
        $zelda.mock('hammerTime', function(fixture) {
        
            // each fixture is a different scenario
            // you could have separate fixtures for various interesting edge cases
            // or forms the data could take
            fixture('default', function(whenGet) {
            
                // this will set up a mock so that calling `hammerTime.getIsHammerTime()` will return
                // a promise that resolves to `true`
                whenGet('isHammerTime').respond(true);
                whenGet('mostRecentHammerTime').respond(new Date('1 Feb 2013'));
                
                // this will set up a mock so that calling `hammerTime.getHasInitiatedHammerTime('Nick Cage')` 
                // will return a promise that resolves with `false`
                whenGet('hasInitiatedHammerTime').withArgs('Nick Cage').respond(false);
            });
            
            // In this case, we decide we want another fixture to develop with a 
            // different form the data could take. You can have arbitrarily many fixtures.
            fixture('notHammerTime', function(whenGet) {
                whenGet('isHammerTime').respond(false);
                whenGet('mostRecentHammerTime').respond(new Date('28 April 2011'));
                
                // withArgs allows you to specify different responses for different
                // arguments. Pass as many args as you'd like!
                whenGet('hasInitiatedHammerTime').withArgs('Nick Cage').respond(true);
                
                // This will make an http request to the url you specify and 
                // return that result.
                whenGet('actuallyIsDolan').respondWithResource('gooby-plz.json');
                
                // If you have to do something cRaZy, you can return your own promise
                whenGet('powerLevel').respondWithPromise(function() {
                    function isOver9000(powerLevel) { 
                        return powerLevel > 9000;
                    }
                
                    var deferred = $q.defer();
                    checkTheScouter().then(function(result) {
                        if (result > 9000) {
                            checkTheScouter().then(function(powerLevel) {
                                deferred.resove(isOver9000(powerLevel);
                            });
                        }
                        deferred.resove(isOver9000(result);
                    });
                    return deferred.promise;
                });
            });
        });
    });

Note that if you call whenGet('foo'), it will produce a method called getFoo().

Because this file contains mocks, it only needs to be included at dev-time. In prod, it can be safely omitted.

As a comment above notes, zelda assumes that your API-object-to-mock already exists. If it doesn't yet, just use app.value('myApi', {}), and you'll be good to go. If your target object does exist, define it as you normally would, and zelda will apply its own definitions on top of it, falling back to the real object when it doesn't have a mock defined.

Unless you are building an app called my-module-name that helps the user determine if it's hammer time or not, you'll probably want to edit the above values.

Assuming that you're running fixture default from above, when you inject hammerTime, you'll get an object that acts like the following:

{
    getIsHammerTime: function() {
        var response = $q.defer();
        response.resolve(true);
        return response.promise;
    }
    
    getMostRecentHammerTime: function() {
        var response = $q.defer();
        response.resolve(new Date('1 Feb 2013'));
        return response.promise;
    }
    
    getHasInitiatedHammerTime: function(name) {
        if (name === 'Nick Cage') {
            var response = $q.defer();
            response.resolve(false);
            return response.promise;
        }
    }
    
    getActuallyIsDolan: function() {
        var response = $q.defer();
        $http.get('gooby-plz.json').success(function(data) {
            response.resolve(data);
        });
        return response.promise;
    }
    
    getPowerLevel: function() {
        var response = $q.defer();
        suppliedPromise().then(function(result) {
            response.resolve(result);
        });
        return response.promise;
    };
}

To switch between fixtures, call $zelda.setActiveFixtureName('someFixtureName') from your code. (In a future version, it may be possible to just use ?fixtureName=someFixtureName in the url.) If no fixture name is set, zelda will pick an arbitrary fixture to use.

Using the mocks

To use the mocks, just inject the service you're wrapping:

angular
  // you must add 'zelda' as a dependency of your angular module,
  // or the dependency injector won't be able to find $zelda
  .module('consumerApp', ['zelda'])
  .directive('billCompare',
    [            'hammerTime',
        function (hammerTime) {
            return {
                link: function postLink($scope, element, attrs) {
                    $scope.isHammerTime = hammerTime.getIsHammerTime();
                  
                    hammerTime.getMostRecentHammerTime().then(function(mostRecentTime) {
                          $scope.timespanSinceHammerTime = getTimespan(mostRecentTime, new Date());
                    });
                    
                    // We didn't mock this out, so it will just fall back
                    // to the real `hammerTime` object
                    $scope.hammerCount = hammerTime.getHammerCount();
                }
            };
        }
    ]
);

The beauty of this approach is that at production time, you simply omit the fixtures.js file from above, and the app will fall back to real data. The app itself doesn't need to change anything to switch the data it's using.

For a more precise sense of going on, just read the code or the tests. They're short.

(function() {
'use strict';
// TODO test fallback / overwriting earlier values
var expect = chai.expect,
FAV_GIFS = 'bar',
FAV_HORSE = 'ohhh yeahh';
angular
.module('consumer', ['zelda'])
.value('bigTimeData', {})
.value('social', {})
.value('dolan', {})
.value('astro', {})
.value('bill_compare', {})
.value('messageProps', {});
beforeEach(function() {
module('consumer');
});
describe('single fixture', function() {
beforeEach(inject(function($zelda) {
$zelda.mock('bigTimeData', function(fixture) {
fixture('gifs', function(whenGet) {
whenGet('fav').respond(FAV_GIFS);
});
});
$zelda.setActiveFixture('gifs');
}));
it('simple getter', inject(function($zelda, bigTimeData) {
bigTimeData.getFav().then(function(fav) {
expect(fav).to.equal(FAV_GIFS);
});
}));
// This is necessary to make $q promises actually fire
afterEach(inject(function($rootScope) {
$rootScope.$digest();
}));
});
describe('three fixtures', function() {
beforeEach(inject(function($zelda) {
$zelda.mock('bigTimeData', function(fixture) {
fixture('gifs', function(whenGet) {
whenGet('fav').respond(FAV_GIFS);
});
fixture('horse', function(whenGet) {
whenGet('fav').respond(FAV_HORSE);
});
fixture('ooooo', function(whenGet) {
});
});
}));
it('setActiveFixtures ', inject(function($zelda, bigTimeData) {
$zelda.setActiveFixture('gifs');
bigTimeData.getFav().then(function(fav) {
expect(fav).to.equal(FAV_GIFS);
});
}));
it('setActiveFixtures 2', inject(function($zelda, bigTimeData) {
$zelda.setActiveFixture('horse');
bigTimeData.getFav().then(function(fav) {
expect(fav).to.equal(FAV_HORSE);
});
}));
// This is necessary to make $q promises actually fire
afterEach(inject(function($rootScope) {
$rootScope.$digest();
}));
});
describe('specify arguments', function() {
beforeEach(inject(function($zelda) {
$zelda.mock('messageProps', function(fixture) {
fixture('en_us', function(whenGet) {
whenGet('mp').withArgs('app.welcome').respond('Hello');
whenGet('mp').withArgs('app.foo').respond('foo message');
});
});
$zelda.setActiveFixture('en_us');
}));
it('should return the right value for the right arguments', inject(function(messageProps) {
messageProps.getMp('app.welcome').then(function(mp) {
expect(mp).to.equal('Hello');
});
messageProps.getMp('app.foo').then(function(mp) {
expect(mp).to.equal('foo message');
});
}));
// This is necessary to make $q promises actually fire
afterEach(inject(function($rootScope) {
$rootScope.$digest();
}));
});
describe('array of arguments', function() {
it('should convert an arguments object to an array', inject(function($$argumentsToArray) {
var args = [1, 2, 3];
(function() {
expect($$argumentsToArray(arguments)).to.eql(args);
}).apply(null, args);
}));
});
describe('multiple mocks', function() {
var FOO_VAL = 'bar',
QUUX_VAL = 'odp';
beforeEach(inject(function($zelda) {
$zelda.mock('astro', function(fixture) {
fixture('default', function(whenGet) {
whenGet('foo').respond(FOO_VAL);
})
});
$zelda.mock('social', function(fixture) {
fixture('default', function(whenGet) {
whenGet('quux').respond(QUUX_VAL);
})
});
$zelda.setActiveFixture('default');
}));
it('should mock out both objects', inject(function(astro, social) {
astro.getFoo().then(function(foo) {
expect(foo).to.equal(FOO_VAL);
});
social.getQuux().then(function(foo) {
expect(foo).to.equal(QUUX_VAL);
});
}));
});
describe('fall back to default fixture if no active fixture is specified', function() {
var ODP_VALUE = 'asdf';
beforeEach(inject(function($zelda) {
$zelda.mock('social', function(fixture) {
fixture('cool', function(whenGet) {
whenGet('odp').respond(ODP_VALUE);
});
});
}));
it('if there is no active fixture, should default to one instead of shitting itself', inject(function(social) {
social.getOdp().then(function(odp) {
expect(odp).to.equal(ODP_VALUE);
})
}));
});
describe('specify a .json file as a fixture', function() {
var jsonContents = {scumbagSteve: "good guy greg", foo: [5, 2, 6]},
jsonContents2 = {odp: "aseasdf", 4: {bar: 'baz'}},
args = ['pls', 9, {ads: 'wt'}];
beforeEach(inject(function($httpBackend, $zelda) {
var jsonFileName = 'fixtures.json',
jsonFileName2 = 'gooby-pls.json';
$httpBackend.whenGET(jsonFileName).respond(jsonContents);
$httpBackend.whenGET(jsonFileName2).respond(jsonContents2);
$zelda.mock('dolan', function(fixture) {
fixture('gooby', function(whenGet) {
whenGet('bogs').respondWithResource(jsonFileName);
whenGet('bogs').withArgs.apply(null, args).respondWithResource(jsonFileName2)
});
});
}));
it('should return the json contents', inject(function(dolan) {
dolan.getBogs().then(function(json) {
expect(json).to.eql(jsonContents);
});
}));
it('should return the json contents for the right args', inject(function(dolan) {
dolan.getBogs.apply(null, args).then(function(json) {
expect(json).to.eql(jsonContents2);
});
}));
afterEach(inject(function($httpBackend, $rootScope) {
$httpBackend.flush();
$rootScope.$digest();
}));
});
describe('specify a promise manually', function() {
var noArgsValue = 4,
argsValue = "asfdasdfasdf",
args = ['asldfj', {}, []];
// TODO could this be simpler? What if we just pass a resolve function that the user calls with `value`?
beforeEach(inject(function($zelda, $q) {
function createDeferredResponse(value) {
return function() {
var deferred = $q.defer();
deferred.resolve(value);
return deferred.promise;
}
}
$zelda.mock('bill_compare', function(fixture) {
fixture('default', function(whenGet) {
whenGet('recentBill').respondWithPromise(createDeferredResponse(noArgsValue));
whenGet('recentBill').withArgs.apply(null, args).respondWithPromise(createDeferredResponse(argsValue));
});
});
}));
it('should return the value resolved by the promise', inject(function(bill_compare) {
bill_compare.getRecentBill().then(function(val) {
expect(val).to.equal(noArgsValue);
})
}));
it('should return the value resolved by the promise for the arguments', inject(function(bill_compare) {
bill_compare.getRecentBill.apply(null, args).then(function(val) {
expect(val).to.equal(argsValue);
})
}));
afterEach(inject(function($httpBackend, $rootScope) {
$rootScope.$digest();
}));
});
}());
(function() {
'use strict';
angular.module('zelda.config', [])
.value('toMockName', 'Error: did you set `toMockName` to name of dependency to mock?');
angular.module('zelda', ['zelda.config']);
/**
* It looks like $provide isn't injectable into a service constructor,
* so we do some hackery.
*/
var $provide;
angular.module('zelda')
.config(function(_$provide_) {
$provide = _$provide_;
})
// TODO do these need to be namespaced?
.factory('$$getFixtureKeys', function() {
return function(fixtures) {
return _.keys(_(fixtures).values().reduce(_.merge, {}));
}
})
.factory('$$makeKey', function() {
return function(propName) {
return 'get' + propName[0].toUpperCase() + propName.substring(1);
}
})
.factory('$$argumentsToArray', function() {
return function(argsObject) {
return [].slice.call(argsObject);
}
})
.service('$zelda',
['$q', '$$getFixtureKeys', '$$makeKey', '$$argumentsToArray', '$http',
function($q, $$getFixtureKeys, $$makeKey, $$argumentsToArray, $http) {
function mock(toMockName, fixtureCalls) {
fixtureCalls(makeDescribeFixture(toMockName));
$provide.decorator(toMockName, function($delegate) {
_($$getFixtureKeys(fixtures[toMockName])).each(function(getterName) {
var origResponse = $delegate[getterName];
$delegate[getterName] = function() {
/**
* We could just overwrite activeFixtureName,
* but I'd rather not have a wide-reaching side effect like that.
*/
var fixtureName =
angular.isDefined(activeFixtureName) ?
activeFixtureName : _.keys(fixtures[toMockName])[0];
return (fixtures[toMockName][fixtureName][getterName][$$argumentsToArray(arguments)] || origResponse)();
}
});
return $delegate;
});
}
var fixtures = {},
activeFixtureName;
function setActiveFixture(fixtureName) {
activeFixtureName = fixtureName;
}
function makeDescribeFixture(toMockName) {
// TODO factor out init calls like this
fixtures[toMockName] = fixtures[toMockName] || {};
return function describeFixture(fixtureName, whenCalls) {
fixtures[toMockName][fixtureName] = fixtures[toMockName][fixtureName] || {};
whenCalls(function whenGet(propName) {
function respond(args, val) {
setFixtureToPromise(args, function(response) {
response.resolve(val);
});
}
function setFixtureToPromise(args, resolve) {
var response = $q.defer(),
getterName = $$makeKey(propName);
resolve(response);
fixtures[toMockName][fixtureName][getterName] = fixtures[toMockName][fixtureName][getterName] || {};
// It's not clear that we always want this to be an error.
// I'm ok for now with saying that it is.
if (angular.isDefined(fixtures[toMockName][fixtureName][getterName][args])) {
throw _.template("Can't redefine existing fixture, but tried to redefine " +
"object = <%= toMockName %>, fixture = <%= fixtureName %>, " +
"getterName = <%= getterName %>, args = <%= args %>", {
toMockName: toMockName,
fixtureName: fixtureName,
getterName: getterName,
args: args
});
}
fixtures[toMockName][fixtureName][getterName][args] = function() {
return response.promise;
};
}
function respondWithResource(args, resourcePath) {
setFixtureToPromise(args, function(response) {
$http.get(resourcePath).success(function(result) {
response.resolve(result);
});
});
}
function respondWithPromise(args, promise) {
setFixtureToPromise(args, function(response) {
promise().then(function(result) {
response.resolve(result);
});
});
}
return {
respond: _.partial(respond, []),
respondWithResource: _.partial(respondWithResource, []),
respondWithPromise: _.partial(respondWithPromise, []),
withArgs: function() {
var arrayArgs = $$argumentsToArray(arguments);
return {
respond: _.partial(respond, arrayArgs),
respondWithResource: _.partial(respondWithResource, arrayArgs),
respondWithPromise: _.partial(respondWithPromise, arrayArgs)
}
}
}
});
};
}
return {
mock: mock,
setActiveFixture: setActiveFixture
};
}
]);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment