Search code examples
angularjsjasminekarma-runnerkarma-jasmineangular-mock

Getting a `$digest already in progress` error when testing a `catch()` error handler


This is my test:

it('add.user() should POST to /users/, failure', function() {
    mockBackend.expectPOST("/users/", {username:'u', password: 'p', email: 'e', location: 'loc'}).respond(400, {msg: "bad request"});

    BaseService.add.user({username:'u', password: 'p', email: 'e', location: 'loc'});

    mockBackend.flush();
});

afterEach(function() {
    mockBackend.verifyNoOutstandingExpectation();
    mockBackend.verifyNoOutstandingRequest();
});

And when I run this test, I get this error:

Chromium 53.0.2785 (Ubuntu 0.0.0) Factory: BaseService add.user() should POST to /users/, failure FAILED
    [object Object] thrown
    Error: [$rootScope:inprog] $digest already in progress
    http://errors.angularjs.org/1.3.15/$rootScope/inprog?p0=%24digest
        at /home/user/Documents/ebdjango/ebdjangoapp/static/js/angular.js:63:12
        at beginPhase (/home/user/Documents/ebdjango/ebdjangoapp/static/js/angular.js:14820:15)
        at Scope.$digest (/home/user/Documents/ebdjango/ebdjangoapp/static/js/angular.js:14262:9)
        at Function.$httpBackend.verifyNoOutstandingExpectation (node_modules/angular-mocks/angular-mocks.js:1557:38)
        at Object.<anonymous> (tests/test_base.js:61:21)

This is BaseService.add.user():

self.add = {
    user: function(user) {
    return $http.post("/users/", user)
        .then(function successHandler(response) {
            return $http.post("/custom-api-auth/login", user)
         }).then(function successHandler(response) {
             $window.location.href = "/";

    // if there are errors, rewrite the error messages
    }).catch(function rejectHandler(errorResponse) {
                     for (prop in errorResponse.data) {
                             if (prop == "email") {
                                 errorResponse.data[prop] = "Please enter a valid email address.";
                             } else if (prop == "username") {
                                 errorResponse.data[prop] = "Username can only contain alphanumeric characters and '.'";
                             } else if (prop == "password") {
                                 errorResponse.data[prop] = "Please enter a valid password";
                             }
                     }
         throw errorResponse;
};

How do I prevent a $digest already in progress error from occurring?

Edit: If I remove throw errorResponse;, the test works But I need throw errorResponse; there because I need to display the errors on the front end (which another controller takes care of.. BaseService.add.user().catch() basically rewrites the errors which should be displayed on the front end).

Edit 2: When the error message says at Object.<anonymous> (tests/test_base.js:61:21) it points to the line: mockBackend.verifyNoOutstandingExpectation();


Solution

  • The problem with the code above is that it throws inside catch block. As opposed to other promise implementations, throwing and rejecting in $q is not the same thing.

    Considering that $q promise chains are executed on digest ($httpBackend.flush() here) synchronously, throwing inside catch block will result in uncaught error, not in rejected promise. It likely prevents a digest from being completed and results in $digest already in progress error on next digest.

    So generally $q.reject should be used for expected errors in promises, while throw should be used only for critical errors. It should be

    return $q.reject(errorResponse);
    

    It is recommended to use Jasmine promise matchers to test it:

    expect(BaseService.add.user({ ... }).toBeRejectedWith({ ... });
    

    Otherwise it has to be tested by more complicated promise chain:

    var noError = new Error;
    
    BaseService.add.user({ ... })
    .then(() => $q.reject(noError))
    .catch((err) => {
      expect(err).not.toBe(noError);
      expect(err).toEqual(...);
    });
    
    $rootScope.$digest();
    

    Location changes should be stubbed in tests, because changing real location in tests is the last thing the one needs:

    module({ $window: {
      location: jasmine.spyObj(['href'])
    } })
    

    And it is preferable to use $location service in Angular applications instead of accessing location directly, unless proven otherwise, $location.path('/...') instead of location.href = '/...'.

    It is already safe for using in tests, although $location.path can be additionally spied for testing.