Handling asynchronous operations in unit tests

Loading

Asynchronous operations, such as HTTP requests, timers, and promises, are common in AngularJS applications. Testing these operations requires special handling to ensure test reliability.

In this guide, we will cover:

  1. Understanding Asynchronous Operations in AngularJS
  2. Using $q and Promises in Unit Tests
  3. Mocking HTTP Calls with $httpBackend
  4. Handling $timeout and $interval in Tests
  5. Using $rootScope.$apply() to Resolve Promises
  6. Using flush() to Control Asynchronous Execution
  7. Best Practices for Testing Asynchronous Code

1. Understanding Asynchronous Operations in AngularJS

Asynchronous operations in AngularJS include:

  • $http for API requests.
  • $q for promises.
  • $timeout and $interval for delayed execution.

Since these operations execute outside the normal test flow, unit tests must handle them explicitly.


2. Using $q and Promises in Unit Tests

AngularJS uses the $q service for promise-based asynchronous operations. We can mock promises in unit tests using $q.defer().

Example: Mocking a Service with $q

Consider this service that returns a promise:

app.service('DataService', function($q) {
this.getData = function() {
var deferred = $q.defer();
setTimeout(() => {
deferred.resolve('Success');
}, 1000);
return deferred.promise;
};
});

Unit Test for the Service

describe('DataService Test', function() {
var DataService, $rootScope;

beforeEach(module('myApp'));

beforeEach(inject(function(_DataService_, _$rootScope_) {
DataService = _DataService_;
$rootScope = _$rootScope_;
}));

it('should resolve the promise with "Success"', function() {
var result;

DataService.getData().then(function(response) {
result = response;
});

// Force promise resolution
$rootScope.$apply();

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

Why $rootScope.$apply()?

$rootScope.$apply() forces the digest cycle, ensuring the promise is resolved immediately.


3. Mocking HTTP Calls with $httpBackend

The $httpBackend service allows us to mock HTTP requests in unit tests.

Example: Service with $http

app.service('ApiService', function($http) {
this.getUsers = function() {
return $http.get('/api/users');
};
});

Unit Test with $httpBackend

describe('ApiService Test', function() {
var ApiService, $httpBackend;

beforeEach(module('myApp'));

beforeEach(inject(function(_ApiService_, _$httpBackend_) {
ApiService = _ApiService_;
$httpBackend = _$httpBackend_;
}));

it('should return user data from API', function() {
var mockResponse = [{ id: 1, name: 'John Doe' }];

// Mock the API call
$httpBackend.whenGET('/api/users').respond(200, mockResponse);

var result;
ApiService.getUsers().then(function(response) {
result = response.data;
});

// Flush pending requests
$httpBackend.flush();

expect(result).toEqual(mockResponse);
});

afterEach(function() {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
});
});

Key Takeaways

$httpBackend.whenGET().respond() mocks HTTP responses.
$httpBackend.flush() forces the response to be processed immediately.
$httpBackend.verifyNoOutstandingRequest() ensures all requests are handled.


4. Handling $timeout and $interval in Tests

AngularJS provides $timeout and $interval for delayed execution. We can use the flush() method to control their execution in tests.

Example: Service with $timeout

app.service('TimerService', function($timeout) {
this.delayedMessage = function() {
var deferred = $timeout(function() {
return 'Hello after 3 seconds';
}, 3000);
return deferred;
};
});

Unit Test with $timeout.flush()

describe('TimerService Test', function() {
var TimerService, $timeout;

beforeEach(module('myApp'));

beforeEach(inject(function(_TimerService_, _$timeout_) {
TimerService = _TimerService_;
$timeout = _$timeout_;
}));

it('should return message after 3 seconds', function() {
var result;

TimerService.delayedMessage().then(function(response) {
result = response;
});

// Simulate 3 seconds passing
$timeout.flush(3000);

expect(result).toBe('Hello after 3 seconds');
});
});

Handling $interval

For $interval, use $interval.flush().

$interval.flush(5000); // Simulates 5 seconds passing

5. Using $rootScope.$apply() to Resolve Promises

AngularJS executes promises inside the digest cycle. When testing, we need to manually trigger the digest cycle using:

$rootScope.$apply();

This ensures that pending promises are resolved immediately.


6. Using flush() to Control Asynchronous Execution

The flush() method controls the execution of asynchronous functions:

  • $httpBackend.flush(): Resolves pending HTTP requests.
  • $timeout.flush(time): Triggers $timeout after a given time.
  • $interval.flush(time): Triggers $interval after a given time.

Using flush() ensures tests run deterministically without actual waiting.


7. Best Practices for Testing Asynchronous Code

Use $httpBackend.flush() to immediately process HTTP calls.
Use $timeout.flush(time) to control time-based execution.
Use $rootScope.$apply() to resolve promises.
Ensure no outstanding requests remain ($httpBackend.verifyNoOutstandingRequest()).
Avoid actual delays in tests. Use mocks instead.

Leave a Reply

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