Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How should the run block be dealt with in Angular unit tests?

My understanding is that when you load your module in Angular unit tests, the run block gets called.

I'd think that if you're testing a component, you wouldn't want to simultaneously be testing the run block, because unit tests are supposed to just test one unit. Is that true?

If so, is there a way to prevent the run block from running? My research leads me to think that the answer is "no", and that the run block always runs when the module is loaded, but perhaps there's a way to override this. If not, how would I test the run block?

Run block:

function run(Auth, $cookies, $rootScope) {
  $rootScope.user = {};
  Auth.getCurrentUser();
}

Auth.getCurrentUser:

getCurrentUser: function() {
  // user is logged in
  if (Object.keys($rootScope.user).length > 0) {
    return $q.when($rootScope.user);
  }
  // user is logged in, but page has been refreshed and $rootScope.user is lost
  if ($cookies.get('userId')) {
    return $http.get('/current-user')
      .then(function(response) {
        angular.copy(response.data, $rootScope.user);
        return $rootScope.user;
      })
    ;
  }
  // user isn't logged in
  else  {
    return $q.when({});
  }
}

auth.factory.spec.js

describe('Auth Factory', function() {
  var Auth, $httpBackend, $rootScope, $cookies, $q;
  var user = {
    username: 'a',
    password: 'password',
  };
  var response = {
    _id: 1,
    local: {
      username: 'a',
      role: 'user'
    }
  };

  function isPromise(el) {
    return !!el.$$state;
  }

  beforeEach(module('mean-starter', 'ngCookies', 'templates'));
  beforeEach(inject(function(_Auth_, _$httpBackend_, _$rootScope_, _$cookies_, _$q_) {
    Auth = _Auth_;
    $httpBackend = _$httpBackend_;
    $rootScope = _$rootScope_;
    $cookies = _$cookies_;
    $q = _$q_;
  }));
  afterEach(function() {
    $httpBackend.verifyNoOutstandingExpectation();
    $httpBackend.verifyNoOutstandingRequest();
  });

  it('#signup', function() {
    $rootScope.user = {};
    $httpBackend.expectPOST('/users', user).respond(response);
    spyOn(angular, 'copy').and.callThrough();
    spyOn($cookies, 'put').and.callThrough();
    var retVal = Auth.signup(user);
    $httpBackend.flush();
    expect(angular.copy).toHaveBeenCalledWith(response, $rootScope.user);
    expect($cookies.put).toHaveBeenCalledWith('userId', 1);
    expect(isPromise(retVal)).toBe(true);
  });

  it('#login', function() {
    $rootScope.user = {};
    $httpBackend.expectPOST('/login', user).respond(response);
    spyOn(angular, 'copy').and.callThrough();
    spyOn($cookies, 'put').and.callThrough();
    var retVal = Auth.login(user);
    $httpBackend.flush();
    expect(angular.copy).toHaveBeenCalledWith(response, $rootScope.user);
    expect($cookies.put).toHaveBeenCalledWith('userId', 1);
    expect(isPromise(retVal)).toBe(true);
  });

  it('#logout', function() {
    $httpBackend.expectGET('/logout').respond();
    spyOn(angular, 'copy').and.callThrough();
    spyOn($cookies, 'remove');
    Auth.logout();
    $httpBackend.flush();
    expect(angular.copy).toHaveBeenCalledWith({}, $rootScope.user);
    expect($cookies.remove).toHaveBeenCalledWith('userId');
  });

  describe('#getCurrentUser', function() {
    it('User is logged in', function() {
      $rootScope.user = response;
      spyOn($q, 'when').and.callThrough();
      var retVal = Auth.getCurrentUser();
      expect($q.when).toHaveBeenCalledWith($rootScope.user);
      expect(isPromise(retVal)).toBe(true);
    });
    it('User is logged in but page has been refreshed', function() {
      $cookies.put('userId', 1);
      $httpBackend.expectGET('/current-user').respond(response);
      spyOn(angular, 'copy').and.callThrough();
      var retVal = Auth.getCurrentUser();
      $httpBackend.flush();
      expect(angular.copy).toHaveBeenCalledWith(response, $rootScope.user);
      expect(isPromise(retVal)).toBe(true);
    });
    it("User isn't logged in", function() {
      $rootScope.user = {};
      $cookies.remove('userId');
      spyOn($q, 'when').and.callThrough();
      var retVal = Auth.getCurrentUser();
      expect($q.when).toHaveBeenCalledWith({});
      expect(isPromise(retVal)).toBe(true);
    });
  });
});

Attempt 1:

beforeEach(module('mean-starter', 'ngCookies', 'templates'));
beforeEach(inject(function(_Auth_, _$httpBackend_, _$rootScope_, _$cookies_, _$q_) {
  Auth = _Auth_;
  $httpBackend = _$httpBackend_;
  $rootScope = _$rootScope_;
  $cookies = _$cookies_;
  $q = _$q_;
}));
beforeEach(function() {
  spyOn(Auth, 'getCurrentUser');
});
afterEach(function() {
  expect(Auth.getCurrentUser).toHaveBeenCalled();
  $httpBackend.verifyNoOutstandingExpectation();
  $httpBackend.verifyNoOutstandingRequest();
});

This doesn't work. The run block is run when the module is loaded, so Auth.getCurrentUser() is called before the spy is set up.

Expected spy getCurrentUser to have been called.

Attempt 2:

beforeEach(inject(function(_Auth_, _$httpBackend_, _$rootScope_, _$cookies_, _$q_) {
  Auth = _Auth_;
  $httpBackend = _$httpBackend_;
  $rootScope = _$rootScope_;
  $cookies = _$cookies_;
  $q = _$q_;
}));
beforeEach(function() {
  spyOn(Auth, 'getCurrentUser');
});
beforeEach(module('mean-starter', 'ngCookies', 'templates'));
afterEach(function() {
  expect(Auth.getCurrentUser).toHaveBeenCalled();
  $httpBackend.verifyNoOutstandingExpectation();
  $httpBackend.verifyNoOutstandingRequest();
});

This doesn't work because Auth isn't available to be injected before my app module is loaded.

Error: [$injector:unpr] Unknown provider: AuthProvider <- Auth

Attempt 3:

As you can see, there's a chicken-egg problem here. I need to inject Auth and set up the spy before the module is loaded, but I can't because Auth isn't available to be injected before the module is loaded.

This blog posts mentions the chicken-egg problem and provides an interesting potential solution. The author proposes that I should create my Auth service manually using $provide before I load my module. Since I'm creating the service, not injecting it, I could do it before the module is loaded, and I could set up the spy. Then when the module is loaded, it'd use this created mock service.

Here is his example code:

describe('example', function () {
    var loggingService;
    beforeEach(function () {
        module('example', function ($provide) {
            $provide.value('loggingService', {
                start: jasmine.createSpy()
            });
        });
        inject(function (_loggingService_) {
            loggingService = _loggingService_;
        });
    });
    it('should start logging service', function() {
        expect(loggingService.start).toHaveBeenCalled();
    });
});

The problem with this, is that I need my Auth service! I only would want to use the mock one for the run block; I need my real Auth service elsewhere so I could test it.

I guess that I could create the actual Auth service using $provide, but that feels wrong.


Final question - for whatever code I end up using to deal with this run block problem, is there a way for me to extract it out so I don't have to re-write it for each of my spec files? The only way I could think to do it would be to use some sort of global function.


auth.factory.js

angular
  .module('mean-starter')
  .factory('Auth', Auth)
;

function Auth($http, $state, $window, $cookies, $q, $rootScope) {
  return {
    signup: function(user) {
      return $http
        .post('/users', user)
        .then(function(response) {
          angular.copy(response.data, $rootScope.user);
          $cookies.put('userId', response.data._id);
          $state.go('home');
        })
      ;
    },
    login: function(user) {
      return $http
        .post('/login', user)
        .then(function(response) {
          angular.copy(response.data, $rootScope.user);
          $cookies.put('userId', response.data._id);
          $state.go('home');
        })
      ;
    },
    logout: function() {
      $http
        .get('/logout')
        .then(function() {
          angular.copy({}, $rootScope.user);
          $cookies.remove('userId');
          $state.go('home');
        })
        .catch(function() {
          console.log('Problem logging out.');
        })
      ;
    },
    getCurrentUser: function() {
      // user is logged in
      if (Object.keys($rootScope.user).length > 0) {
        return $q.when($rootScope.user);
      }
      // user is logged in, but page has been refreshed and $rootScope.user is lost
      if ($cookies.get('userId')) {
        return $http.get('/current-user')
          .then(function(response) {
            angular.copy(response.data, $rootScope.user);
            return $rootScope.user;
          })
        ;
      }
      // user isn't logged in
      else  {
        return $q.when({});
      }
    }
  };
}

Edit - failed attempt + successful attempt:

beforeEach(module('auth'));
beforeEach(inject(function(_Auth_) {
  Auth = _Auth_;
  spyOn(Auth, 'requestCurrentUser');
}));
beforeEach(module('mean-starter', 'ngCookies', 'templates'));
beforeEach(inject(function(_Auth_, _$httpBackend_, _$rootScope_, _$cookies_, _$q_) {
  // Auth = _Auth_;
  $httpBackend = _$httpBackend_;
  $rootScope = _$rootScope_;
  $cookies = _$cookies_;
  $q = _$q_;
}));
// beforeEach(function() {
//   spyOn(Auth, 'getCurrentUser');
// });
afterEach(function() {
  expect(Auth.getCurrentUser).toHaveBeenCalled();
  $httpBackend.verifyNoOutstandingExpectation();
  $httpBackend.verifyNoOutstandingRequest();
});

I'm not sure why this wouldn't work (independent of the problem with using inject twice).

I was trying to get around having to use $provide as that initially felt hacky/weird to me. After thinking about it some more though, I now feel that $provide is fine, and that following your suggestion to use mock-auth is fantastic!!! Both worked for me.

In auth.factory.spec.js I just loaded the auth module (I'm calling it auth, not mean-auth) without loading mean-starter. This doesn't have the run block problem because that module doesn't have the run block code, but it allows me to test my Auth factory. Elsewhere, this works:

beforeEach(module('mean-starter', 'templates', function($provide) {
  $provide.value('Auth', {
    requestCurrentUser: jasmine.createSpy()
  });
}));

As does the fantastic mock-auth solution:

auth.factory.mock.js

angular
  .module('mock-auth', [])
  .factory('Auth', Auth)
;

function Auth() {
  return {
    requestCurrentUser: jasmine.createSpy()
  };
}

user.service.spec.js

beforeEach(module('mean-starter', 'mock-auth', 'templates'));
like image 616
Adam Zerner Avatar asked Aug 23 '15 17:08

Adam Zerner


1 Answers

My understanding is that when you load your module in Angular unit tests, the run block gets called.

Correct.

I'd think that if you're testing a component, you wouldn't want to simultaneously be testing the run block, because unit tests are supposed to just test one unit. Is that true?

Also correct, in that right now you are effectively testing the integration of Auth and your run block, and there is no isolation of one from the other.

If so, is there a way to prevent the run block from running? My research leads me to think that the answer is "no", and that the run block always runs when the module is loaded, but perhaps there's a way to override this. If not, how would I test the run block?

As implemented, no you cannot prevent the run block from running. However, it remains possible with some minor refactoring as your question is ultimately one of modularisation. Without being able to see your module declaration, I would imagine it looks something like this:

angular.module('mean-starter', ['ngCookies'])

  .factory('Auth', function($cookies) {
    ...
  });

  .run(function(Auth, $rootScope) {
    ...
  });

This pattern can be broken into modules to support testability (and module reusability):

angular.module('mean-auth', ['ngCookies'])

  .factory('Auth', function() {
    ...
  });

angular.module('mean-starter', ['mean-auth'])

  .run(function(Auth, $rootScope) {
    ...
  });

This now allows you to test your Auth factory in isolation by loading the mean-auth module only into its test.

While this solves the problem of your run block interfering with your unit tests for Auth, you still face the problem of mocking Auth.getCurrentUser so as to test your run block in isolation. The blog post you referenced is correct in that you should be looking to leverage the configuration stage of the module to stub/spy on dependencies used during the run stage. Therefore, in your test:

module('mean-starter', function ($provide) {
  $provide.value('Auth', {
    getCurrentUser: jasmine.createSpy()
  });
});

As to your final question, you can create reusable mocks by declaring them as modules. For example, if you wanted to create a reusable mock factory for Auth you define it in a separate file loaded prior to your unit tests:

angular.module('mock-auth', [])

 .factory('Auth', function() {
   return {
     getCurrentUser: jasmine.createSpy()
   };
 });

and then load it in your tests subsequent to any module in which you require it, as angular will overwrite any service with the same name:

module('mean-starter', 'mock-auth');
like image 99
scarlz Avatar answered Sep 20 '22 14:09

scarlz