$timeout not resolving correctly in unit tests

Loading

When $timeout doesn’t resolve correctly in AngularJS unit tests, it typically causes your expectations to fail or your async logic to never complete. This issue often arises due to either forgetting to flush $timeout, mocking it improperly, or mismanaging the digest cycle.

In this comprehensive explanation, we’ll break down the issue, show proper usage, and explain step-by-step how to ensure $timeout works as expected in your Jasmine unit tests.


Understanding the Problem

AngularJS’s $timeout is a wrapper around JavaScript’s setTimeout, and it’s integrated with Angular’s digest cycle. When you use $timeout in your code, Angular tracks it and triggers a digest after the timeout is resolved.

However, in unit tests, $timeout does not resolve automatically—you have to manually flush it using $timeout.flush().


Correct Usage of $timeout in Unit Tests

Here’s a simple service that uses $timeout:

app.js

angular.module('app', [])
.service('delayedService', function($timeout) {
  this.delayedAction = function(callback) {
    $timeout(function() {
      callback('done');
    }, 1000);
  };
});

Working Unit Test

describe('delayedService', function() {
  var delayedService, $timeout;

  beforeEach(module('app'));

  beforeEach(inject(function(_delayedService_, _$timeout_) {
    delayedService = _delayedService_;
    $timeout = _$timeout_;
  }));

  it('should call callback after timeout', function() {
    var result;
    delayedService.delayedAction(function(response) {
      result = response;
    });

    expect(result).toBeUndefined();

    $timeout.flush(); // Forces timeout to resolve immediately

    expect(result).toBe('done');
  });
});

🧪 Common Mistakes and How to Fix Them

Mistake 1: Not calling $timeout.flush()

  • Symptoms: Test passes too soon, $timeout hasn’t executed the callback yet.
  • Fix: Always call $timeout.flush() after triggering a $timeout.

Mistake 2: Forgetting $digest when using $applyAsync or promise chains

  • If $timeout resolves a promise or triggers a digest, and you’re testing logic that depends on it, you might need to manually trigger $rootScope.$digest().

Mistake 3: Expecting $timeout.flush() to work without a scheduled timeout

  • This throws an error: No deferred tasks to be flushed.
  • Fix: Wrap flush in expect only after a $timeout-based method is called.

Testing Promise-based Services with $timeout

Example:

angular.module('app')
.service('promiseService', function($timeout, $q) {
  this.getAsync = function() {
    var deferred = $q.defer();
    $timeout(function() {
      deferred.resolve('async result');
    }, 500);
    return deferred.promise;
  };
});

Test:

describe('promiseService', function() {
  var promiseService, $timeout, $rootScope;

  beforeEach(module('app'));

  beforeEach(inject(function(_promiseService_, _$timeout_, _$rootScope_) {
    promiseService = _promiseService_;
    $timeout = _$timeout_;
    $rootScope = _$rootScope_;
  }));

  it('should resolve promise after timeout', function() {
    var result;

    promiseService.getAsync().then(function(data) {
      result = data;
    });

    $timeout.flush(); // resolve the timeout
    $rootScope.$digest(); // resolve the promise

    expect(result).toBe('async result');
  });
});

Best Practices for $timeout in Unit Tests

PracticeDescription
flush()Always use $timeout.flush() to simulate time passing.
verify()Optionally use $timeout.verifyNoPendingTasks() to ensure no timeouts remain.
flush(ms)Use $timeout.flush(ms) to simulate partial timeout progression.
inject()Always inject $timeout and $rootScope if working with promises.
avoid real timeNever use setTimeout or rely on real clock time in unit tests.

Example with Controller and $timeout

Controller:

angular.module('app')
.controller('TimerCtrl', function($scope, $timeout) {
  $scope.message = 'Waiting...';
  $timeout(function() {
    $scope.message = 'Updated after timeout';
  }, 2000);
});

Test:

describe('TimerCtrl', function() {
  var $controller, $timeout, $rootScope, $scope;

  beforeEach(module('app'));

  beforeEach(inject(function(_$controller_, _$timeout_, _$rootScope_) {
    $controller = _$controller_;
    $timeout = _$timeout_;
    $rootScope = _$rootScope_;
    $scope = $rootScope.$new();
  }));

  it('should update message after timeout', function() {
    $controller('TimerCtrl', { $scope: $scope });

    expect($scope.message).toBe('Waiting...');

    $timeout.flush(); // Trigger $timeout

    expect($scope.message).toBe('Updated after timeout');
  });
});

Debugging Tips

  • If nothing happens after flush: check if the timeout is actually being set.
  • If flushing causes an error: maybe no timeout was queued—wrap in try/catch for clarity.
  • Check $timeout delay value. $timeout.flush() without a parameter resolves all pending timeouts.
  • Use spyOn($timeout, 'flush') if you want to test whether flush was called in large test suites.

Cleanup

Sometimes tests leave $timeout tasks hanging. To avoid that:

afterEach(function() {
  try {
    $timeout.verifyNoPendingTasks();
  } catch (e) {
    console.warn('Timeouts pending at end of test');
  }
});

Leave a Reply

Your email address will not be published. Required fields are marked *