How do you mock an angularjs $resource factory

Problem

I have a resource factory

angular.module('mean.clusters').factory('Clusters', ['$resource',
  function($resource) {
    return $resource('clusters/:clusterId/:action', {
        clusterId: '@_id'
    }, {
        update: {method: 'PUT'},
        status: {method: 'GET', params: {action:'status'}}
    });
}]);

and a controller

angular.module('mean.clusters').controller('ClustersController', ['$scope',
  '$location', 'Clusters',
    function ($scope, $location, Clusters) {
        $scope.create = function () {
            var cluster = new Clusters();

            cluster.$save(function (response) {
                $location.path('clusters/' + response._id);
            });
        };

        $scope.update = function () {
            var cluster = $scope.cluster;

            cluster.$update(function () {
                $location.path('clusters/' + cluster._id);
            });
        };


        $scope.find = function () {
            Clusters.query(function (clusters) {
                $scope.clusters = clusters;

            });
        };
}]);

I am writing my unit tests and every example I find is using some form of $httpBackend.expect to mock the response from the server, and I can do that just fine.

My problems is, when unit testing my controller functions I would like to mock the Clusters object. If I'm using $httpBackend.expect, and I introduce a bug in my factory every unit test in my controller will fail.

I would like to have my test of $scope.create test only $scope.create and not also my factory code.

I've tried adding a provider in the beforeEach(module('mean', function ($provide) { part of my tests but I cant seem to get it right.

I also tried

clusterSpy = function (properties){
    for(var k in properties)
        this[k]=properties[k];
};

clusterSpy.$save = jasmine.createSpy().and.callFake(function (cb) {
    cb({_id: '1'});
});

and setting Clusters = clusterSpy; in the before(inject but in the create function, the spy gets lost with

Error: Expected a spy, but got Function.

I have been able to get a spy object to work for the cluster.$update type calls but then it fails at var cluster = new Clusters(); with a 'not a function' error.

I can create a function that works for var cluster = new Clusters(); but then fails for the cluster.$update type calls.

I'm probably mixing terms here but, is there a proper way to mock Clusters with spies on the functions or is there a good reason to just go with $httpBackend.expect?

Problem courtesy of: Diver

Solution

Looks like I was close a few times but I think I have it figured out now.

The solution was the 'I also tried' part above but I was not returning the spy object from the function.

This works, it can be placed in either the beforeEach(module( or beforeEach(inject sections

Step 1: create the spy object with any functions you want to test and assign it to a variable that's accessible to your tests.

Step 2: make a function that returns the spy object.

Step 3: copy the properties of the spy object to the new function.

clusterSpy = jasmine.createSpyObj('Clusters', ['$save', 'update', 'status']);

clusterSpyFunc = function () {
    return clusterSpy
};

for(var k in clusterSpy){
    clusterSpyFunc[k]=clusterSpy[k];
}

Step 4: add it to the $controller in the beforeEach(inject section.

ClustersController = $controller('ClustersController', {
    $scope: scope,
    Clusters: clusterSpyFunc
});

inside your tests you can still add functionality to the methods using

clusterSpy.$save.and.callFake(function (cb) {
    cb({_id: '1'});
});

then to check the spy values

expect(clusterSpy.$save).toHaveBeenCalled();

This solves both problems of new Clusters() and Clusters.query not being a function. And now I can unit test my controller with out a dependency on the resource factory.

Solution courtesy of: Diver

Discussion

Another way to mock the Clusters service is this:

describe('Cluster Controller', function() {

    var location, scope, controller, MockClusters, passPromise, q;
    var cluster = {_id : '1'};

    beforeEach(function(){

      // since we are outside of angular.js framework,
      // we inject the angujar.js services that we need later on
      inject(function($rootScope, $controller, $q) {
        scope = $rootScope.$new();
        controller = $controller;
        q = $q;
      });

      // let's mock the location service
      location = {path: jasmine.createSpy('path')};

      // let's mock the Clusters service
      var MockClusters = function(){};
      // since MockClusters is a function object (not literal object)
      // we'll need to use the "prototype" property
      // for adding methods to the object
      MockClusters.prototype.$save = function(success, error) {
        var deferred = q.defer();
        var promise = deferred.promise;

        // since the Clusters controller expect the result to be
        // sent back as a callback, we register the success and
        // error callbacks with the promise
        promise.then(success, error);

        // conditionally resolve the promise so we can test
        // both paths
        if(passPromise){
          deferred.resolve(cluster);
        } else {
          deferred.reject();
        }
      }

      // import the module containing the Clusters controller
      module('mean.clusters')
      // create an instance of the controller we unit test
      // using the services we mocked (except scope)
      controller('ClustersController', {
        $scope: scope,
        $location: location,
        Clusters: MockClusters
      });


    it('save completes successfully', function() {
      passPromise = true;
      scope.save();

      // since MockClusters.$save contains a promise (e.g. an async call)
      // we tell angular to process this async call before we can validate
      // the response
      scope.$apply();

      // we can call "toHaveBeenCalledWith" since we mocked "location.path" as a spy
      expect(location.path).toHaveBeenCalledWith('clusters/' + cluster._id););

    });

    it('save doesn''t complete successfully', function() {
      passPromise = false;
      scope.save();

      // since MockClusters.$save contains a promise (e.g. an async call)
      // we tell angular to process this async call before we can validate
      // the response
      scope.$apply();

      expect(location.path).toHaveBeenCalledWith('/error'););

    });

  });

});
Discussion courtesy of: claudius

This recipe can be found in it's original form on Stack Over Flow.