austin
austin

Reputation: 5866

Nested AngularJS promises not working in unit tests

I am learning how to use AngularJS promises and I'm having a problem writing unit tests for them. I wrote a module with a factory that can query for an RSS feed, parse the titles from the articles, and return a promise that will resolve an array of the title strings.

Here is the module code:

angular.module('rss', [])

.factory('rssService', ['$http', '$q', '$rootScope', function($http, $q, 
    $rootScope) {
  return function(url) {

    this.getTitles = function() {
      var deferred = $q.defer(),
        titles = [];

      $http({
        method: 'GET',
        url: url
      }).success(function(data) {
        $(data).find('title').each(function(index, item) {
          var title = $(item).text();
          console.log(title);
          titles.push(title);
        });
        deferred.resolve(titles);
      });
      return deferred.promise;
    };
  };
}])
;

And here is my Jasmine unit test code:

beforeEach(module('rss'));

describe("RSS Module", function() {

  var rssService, $httpBackend;

  beforeEach(inject(function(_rssService_, _$httpBackend_) {
    rssService = new _rssService_('/rss');
    $httpBackend = _$httpBackend_;
  }));

  it("Can parse an RSS Feed and get the titles", function(done) {
    $httpBackend.expectGET('/rss').respond(mockRSS); // mock data declared earlier
    var titles =  rssService.getTitles();
    $httpBackend.flush();

    titles.then(function(data) {
      console.log("Done!");
      expect(data.length).toBe(17);
      done();
    });


  });
});

When I run the unit test, I get the following error:

Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.

Which is because, while my promise resolved, it did not make a call to $apply the changes. The solution I've read about was to inject a $rootScope and call $rootScope.$apply() after I resolve the promise. Doing this though results in the following error

Error: [$rootScope:inprog] $digest already in progress

which is because I'm calling $rootScope.$apply() inside of the success() block, so a digest is already happening.

Outside of a unit test (using an actual RSS feed) the code works fine. The controller's scope handles the digest and things work as expected.

Is there any way around this?

Upvotes: 0

Views: 2613

Answers (2)

Michal Charemza
Michal Charemza

Reputation: 27012

Your answer is right about the order of flush and then. The flush should be after the callbacks have been defined.

Also, when you're using flush from $httpBackend, or from $timeout, there is no need to use done: the test becomes synchronous. So the test can be:

it("Can parse an RSS Feed and get the titles", function() {
  $httpBackend.expectGET('/rss').respond(mockRSS); // mock data declared earlier
  rssService.getTitles().then(function(data) {
    expect(data.length).toBe(17);
  });
  $httpBackend.flush();
});

Also, I don't think directly related to your issue, but there isn't usually a need to manually create a promise when you want to post-process the result of $http. Using the then callback of $http, instead of the custom success, you can use standard promise chaining:

this.getTitles = function() {
  return $http({
    method: 'GET',
    url: url
  }).then(function(response) {
    var derivedData = {length: response.data.length};
    return derivedData;
  });
};

You can see this at http://plnkr.co/edit/efTzOU9RYhVIgLVMdKkd .

Upvotes: 4

austin
austin

Reputation: 5866

The solution I found was to move my line of code that flushed the $httpBackend to after the code to declare the then block. What was happening was the GET request was being flushed and my promise would be declared and resolved before I had given it a then.

The new unit test code looks like this:

it("Can parse an RSS Feed and get the titles", function(done) {
  $httpBackend.expectGET('/rss').respond(mockRSS);
  var titles =  rssService.getTitles();

  titles.then(function(data) {
    console.log("Done!");
    expect(data.length).toBe(17);
    done();
  });

  $httpBackend.flush();
});

I didn't have to change any of my actual module code, but I'm currious if there is a better way to write it using chained promises. Comments or additional answers are very welcome.

Upvotes: 0

Related Questions