![]()
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,
$timeouthasn’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
$timeoutresolves 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
expectonly 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
| Practice | Description |
|---|---|
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 time | Never 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
$timeoutdelay 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');
}
});
