6

I'm trying to get chai-as-promised to work with $q promises with karma unit tests.

  svc.test = function(foo){
    if (!foo){
      // return Promise.reject(new Error('foo is required'));
      return $q.reject(new Error('foo is required'));
    } else {
      // get data via ajax here
      return $q.resolve({});
    }
  };


  it.only('should error on no foo', function(){
    var resolvedValue = MyServices.test();
    $rootScope.$apply();
    return resolvedValue.should.eventually.be.rejectedWith(TypeError, 'foo is required');
  });

The unit test just times out. I am not sure what I'm doing wrong here to get the promise to resolve properly. It seems to be an issue with using $q -- when I use native Promise.reject() it works fine.

I filed a ticket here, but nobody seems to be responding: https://github.com/domenic/chai-as-promised/issues/150

chovy
  • 72,281
  • 52
  • 227
  • 295

2 Answers2

3

The way chai-as-promised expects to modify promise assertions is transferPromiseness method.

By default, the promises returned by Chai as Promised's assertions are regular Chai assertion objects, extended with a single then method derived from the input promise. To change this behavior, for instance to output a promise with more useful sugar methods such as are found in most promise libraries, you can override chaiAsPromised.transferPromiseness.

For Angular 1.3+ support, $q promises can be duck-typed by $$state property, so native promises won't be affected:

chaiAsPromised.transferPromiseness = function (assertion, promise) {
  assertion.then = promise.then.bind(promise);

  if (!('$$state' in promise))
    return;

  inject(function ($rootScope) {
    if (!$rootScope.$$phase)
      $rootScope.$digest();
  });
};

chaiAsPromised chains each asserted promise with then. Even if the promise is settled, the rest of the chain still requires the digest to be triggered manually with $rootScope.$digest().

As long as the spec contains no asynchronous code, it becomes synchronous, no promise is required to be returned:

it('...', () => {
  ...
  expect(...).to.eventually...;
  expect(...).to.eventually...;
});

And is equal to mandatory $rootScope.$digest() after each set of eventually assertions/expectation when transferPromiseness wasn't set:

it('...', () => {
  ...
  expect(...).to.eventually...;
  expect(...).to.eventually...;
  $rootScope.$digest();
});
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Seems like something that should be provided by a plugin since angular/$q is so common and most companies use chai with it. – chovy Jun 03 '16 at 02:14
  • @chovy Sure, why not, the one could create a wrapper package for chai-as-promised that reexports it with listed code (fortunately, it doesn't even have to be forked for that). I've got used to the idea that Mocha/Chai bundle requires setup file any way, it's not a problem for me to have extra several lines there. – Estus Flask Jun 03 '16 at 11:27
  • @Willa Right after [`var chaiAsPromised = require("chai-as-promised")`](https://github.com/domenic/chai-as-promised#installation-and-setup). If the specs run in Karma or browser environment, it should be placed in a script that is loaded before all specs. – Estus Flask Jun 06 '16 at 11:19
  • Thanks! Sorry though I didn't mention that I am running karma using grunt. So all I have is a karma config file. To be precise it is karma mocha & chai – Willa Jun 06 '16 at 14:59
  • @Willa You may have something like `files: ['test/setup.js', 'test/*.spec.js']` in Karma config. They are running in specified order, the first one is where the code above should go to. – Estus Flask Jun 06 '16 at 15:10
  • Thanks @estus I have found it but it turns out I have to setup requirejs and a bunch other files just to make this work! I guess I will come back to this sometime later. – Willa Jun 06 '16 at 16:01
  • It turns out I did not need to use require. Unfortunately it does not work! I am still getting timeouts. – Willa Jun 06 '16 at 16:10
  • @Willa I guess it has something to do with your Karma setup rather than with the question. It should work if [`chaiAsPromised.transferPromiseness` was re-assigned before the spec](http://plnkr.co/edit/x6TNFlXtgyr9QkkSq4PK?p=preview). Feel free to open a question if it doesn't work for you for some reason. – Estus Flask Jun 06 '16 at 16:25
  • That is what I suspected initially but I made sure it was re-assigned because I console logged that function just immediately using chai-as-promised API and it showed your code meaning it was overridden successfully. – Willa Jun 06 '16 at 16:31
  • @Willa It is hard to say what's going wrong. If it is possible to recreate an issue in plunker, this may help. – Estus Flask Jun 06 '16 at 17:42
  • @estus thanks for putting up with me buddy. Unfortunately I have to take care of some other thing that will eat up my time. I will try to see if I can reproduce in plunker. Thanks again! – Willa Jun 08 '16 at 20:28
  • Where does the `inject()` method come from? Edit: This comes from ngMock, which I'm not using. "This function is declared ONLY WHEN running tests with jasmine or mocha." Is there a solution for running e2e tests in production with protractor/CucumberJS? – hackel Jan 23 '17 at 16:40
  • @hackel I don't think that this question is specific to chai-as-promised. You need the access to current application's injector, after that you can get $rootScope with `$injector.get('$rootScope')` (that's what `inject` basically does). – Estus Flask Jan 23 '17 at 17:24
1

You need to change the order of execution in your tests. Asynchronous tasks with chai-as-promised need to happen before the expectation.

it('does not work', () => {
  $timeout.flush();
  expect(myAsyncTask()).to.eventually.become('foo');
})

it('does work', () => {
  expect(myAsyncTask()).to.eventually.become('foo');
  $timeout.flush();      
})

You need to initiate the call to the asynchronous task before flushing the queue of asynchronous tasks.

Also, don't use $rootScope.$digest. That may have other side effects that are not desirable within your test(s).

$timeout.flush is what you're looking for.

https://docs.angularjs.org/api/ngMock/service/$timeout


To get your specific test(s) working:

it('should error on no foo', function(){
  MyServices.test().should.eventually.be.rejectedWith(TypeError, 'foo is required')
  $rootScope.$apply();
});

it('should pass on foo', function(){
  MyServices.test('foo').should.eventually.become({});
  $rootScope.$apply();      
}

tl;dr

it('async test', () => {
  setup();
  expect();
  execute();
})

it('sync test', () => {
  setup();
  execute();
  expect();
})

Given the comments posted:

Should it be mentioned that it is unethical to downvote 'rival' answers on the question you're answering?

Fair enough. I think the answer is misleading, given that there is no extra setup necessary to get chai-as-promised working with Angular without having to deal with the done callback. Fwiw, I'll go ahead and try to revoke said downvote and be ethical about it.

The OP has no signs of timeout in his code and doesn't state that the task is asynchronous. $rootScope.$digest() has no side effects in specs when called outside of scope digest. The reason why it is not recommended in production is because it doesn't have the safeguards that $apply has.

$rootScope.$digest is effectively the same as $rootScope.$apply (and $scope.$apply for that matter). source

$timeout.flush will flush non-$timeout based functions just as well. It is not exclusive to $timeout based functions.

Plunker to showcase how it just works™: plunker

  • The OP has no signs of timeout in his code and doesn't state that the task is asynchronous. `$rootScope.$digest()` has *no* side effects in specs when called outside of scope digest. The reason why it is not recommended in production is because it doesn't have the safeguards that `$apply` has. – Estus Flask Jul 06 '16 at 12:07
  • @estus I've tried to answer your concerns to the best of my ability. Also, do check out the [plunker](http://plnkr.co/edit/4J3oIDSMIG7bfn9svDvq?p=preview) if you're uncertain as to whether this approach would work or not for 'synchronous' tasks. –  Jul 06 '16 at 12:43
  • Sure, `$timeout.flush()` works, but it looks like an overkill here, and overkill isn't something that should be done mechanically in specs. If there are unflushed timeouts, the tester may be aware of them and flush them consciously. Otherwise a mere `$rootScope.$digest()` after `eventually` is enough. – Estus Flask Jul 06 '16 at 13:14
  • That's essentially what `transferPromiseness` hook does, I've updated the answer to show that the spec is sync with it and [shouldn't return a promise](http://stackoverflow.com/questions/37974675/do-i-really-need-to-return-a-promise-in-test-when-using-chai-as-promised). The one may place `$timeout.flush()` instead of `$digest()` into `transferPromiseness` if he/she feels that it fits well, though I wouldn't recommend that for the reasons listed above. – Estus Flask Jul 06 '16 at 13:22