Mocking $modal in AngularJS unit tests
Solution 1
When you spy on the $modal.open function in the beforeEach,
spyOn($modal, 'open').andReturn(fakeModal);
or
spyOn($modal, 'open').and.returnValue(fakeModal); //For Jasmine 2.0+
you need to return a mock of what $modal.open normally returns, not a mock of $modal, which doesn’t include an open
function as you laid out in your fakeModal
mock. The fake modal must have a result
object that contains a then
function to store the callbacks (to be called when the OK or Cancel buttons are clicked on). It also needs a close
function (simulating an OK button click on the modal) and a dismiss
function (simulating a Cancel button click on the modal). The close
and dismiss
functions call the necessary call back functions when called.
Change the fakeModal
to the following and the unit test will pass:
var fakeModal = {
result: {
then: function(confirmCallback, cancelCallback) {
//Store the callbacks for later when the user clicks on the OK or Cancel button of the dialog
this.confirmCallBack = confirmCallback;
this.cancelCallback = cancelCallback;
}
},
close: function( item ) {
//The user clicked OK on the modal dialog, call the stored confirm callback with the selected item
this.result.confirmCallBack( item );
},
dismiss: function( type ) {
//The user clicked cancel on the modal dialog, call the stored cancel callback
this.result.cancelCallback( type );
}
};
Additionally, you can test the cancel dialog case by adding a property to test in the cancel handler, in this case $scope.canceled
:
$scope.modalInstance.result.then(function (selectedItem) {
$scope.selected = selectedItem;
}, function () {
$scope.canceled = true; //Mark the modal as canceled
$log.info('Modal dismissed at: ' + new Date());
});
Once the cancel flag is set, the unit test will look something like this:
it("should cancel the dialog when dismiss is called, and $scope.canceled should be true", function () {
expect( scope.canceled ).toBeUndefined();
scope.open(); // Open the modal
scope.modalInstance.dismiss( "cancel" ); //Call dismiss (simulating clicking the cancel button on the modal)
expect( scope.canceled ).toBe( true );
});
Solution 2
To add to Brant's answer, here is a slightly improved mock that will let you handle some other scenarios.
var fakeModal = {
result: {
then: function (confirmCallback, cancelCallback) {
this.confirmCallBack = confirmCallback;
this.cancelCallback = cancelCallback;
return this;
},
catch: function (cancelCallback) {
this.cancelCallback = cancelCallback;
return this;
},
finally: function (finallyCallback) {
this.finallyCallback = finallyCallback;
return this;
}
},
close: function (item) {
this.result.confirmCallBack(item);
},
dismiss: function (item) {
this.result.cancelCallback(item);
},
finally: function () {
this.result.finallyCallback();
}
};
This will allow the mock to handle situations where...
You use the modal with the .then()
, .catch()
and .finally()
handler style instead passing 2 functions (successCallback, errorCallback
) to a .then()
, for example:
modalInstance
.result
.then(function () {
// close hander
})
.catch(function () {
// dismiss handler
})
.finally(function () {
// finally handler
});
Solution 3
Since modals use promises you should definitely use $q for such things.
Code becomes:
function FakeModal(){
this.resultDeferred = $q.defer();
this.result = this.resultDeferred.promise;
}
FakeModal.prototype.open = function(options){ return this; };
FakeModal.prototype.close = function (item) {
this.resultDeferred.resolve(item);
$rootScope.$apply(); // Propagate promise resolution to 'then' functions using $apply().
};
FakeModal.prototype.dismiss = function (item) {
this.resultDeferred.reject(item);
$rootScope.$apply(); // Propagate promise resolution to 'then' functions using $apply().
};
// ....
// Initialize the controller and a mock scope
beforeEach(inject(function ($controller, $rootScope) {
scope = $rootScope.$new();
fakeModal = new FakeModal();
MainCtrl = $controller('MainCtrl', {
$scope: scope,
$modal: fakeModal
});
}));
// ....
it("should cancel the dialog when dismiss is called, and $scope.canceled should be true", function () {
expect( scope.canceled ).toBeUndefined();
fakeModal.dismiss( "cancel" ); //Call dismiss (simulating clicking the cancel button on the modal)
expect( scope.canceled ).toBe( true );
});
Solution 4
Brant's answer was clearly awesome, but this change made it even better for me:
fakeModal =
opened:
then: (openedCallback) ->
openedCallback()
result:
finally: (callback) ->
finallyCallback = callback
then in the test area:
finallyCallback()
expect (thing finally callback does)
.toEqual (what you would expect)
coderigo
Updated on November 04, 2020Comments
-
coderigo over 3 years
I'm writing a unit test for a controller that fires up a
$modal
and uses the promise returned to execute some logic. I can test the parent controller that fires the $modal, but I can't for the life of me figure out how to mock a successful promise.I've tried a number of ways, including using
$q
and$scope.$apply()
to force the resolution of the promise. However, the closest I've gotten is putting together something similar to the last answer in this SO post;I've seen this asked a few times with the "old"
$dialog
modal. I can't find much on how to do it with the "new"$dialog
modal.Some pointers would be tres appreciated.
To illustrate the problem I'm using the example provided in the UI Bootstrap docs, with some minor edits.
Controllers (Main and Modal)
'use strict'; angular.module('angularUiModalApp') .controller('MainCtrl', function($scope, $modal, $log) { $scope.items = ['item1', 'item2', 'item3']; $scope.open = function() { $scope.modalInstance = $modal.open({ templateUrl: 'myModalContent.html', controller: 'ModalInstanceCtrl', resolve: { items: function() { return $scope.items; } } }); $scope.modalInstance.result.then(function(selectedItem) { $scope.selected = selectedItem; }, function() { $log.info('Modal dismissed at: ' + new Date()); }); }; }) .controller('ModalInstanceCtrl', function($scope, $modalInstance, items) { $scope.items = items; $scope.selected = { item: $scope.items[0] }; $scope.ok = function() { $modalInstance.close($scope.selected.item); }; $scope.cancel = function() { $modalInstance.dismiss('cancel'); }; });
The view (main.html)
<div ng-controller="MainCtrl"> <script type="text/ng-template" id="myModalContent.html"> <div class="modal-header"> <h3>I is a modal!</h3> </div> <div class="modal-body"> <ul> <li ng-repeat="item in items"> <a ng-click="selected.item = item">{{ item }}</a> </li> </ul> Selected: <b>{{ selected.item }}</b> </div> <div class="modal-footer"> <button class="btn btn-primary" ng-click="ok()">OK</button> <button class="btn btn-warning" ng-click="cancel()">Cancel</button> </div> </script> <button class="btn btn-default" ng-click="open()">Open me!</button> <div ng-show="selected">Selection from a modal: {{ selected }}</div> </div>
The test
'use strict'; describe('Controller: MainCtrl', function() { // load the controller's module beforeEach(module('angularUiModalApp')); var MainCtrl, scope; var fakeModal = { open: function() { return { result: { then: function(callback) { callback("item1"); } } }; } }; beforeEach(inject(function($modal) { spyOn($modal, 'open').andReturn(fakeModal); })); // Initialize the controller and a mock scope beforeEach(inject(function($controller, $rootScope, _$modal_) { scope = $rootScope.$new(); MainCtrl = $controller('MainCtrl', { $scope: scope, $modal: _$modal_ }); })); it('should show success when modal login returns success response', function() { expect(scope.items).toEqual(['item1', 'item2', 'item3']); // Mock out the modal closing, resolving with a selected item, say 1 scope.open(); // Open the modal scope.modalInstance.close('item1'); expect(scope.selected).toEqual('item1'); // No dice (scope.selected) is not defined according to Jasmine. }); });
-
coderigo about 10 yearsbrilliant! Thanks so much. I had completely missed what the open function actually returned and that I was trying to mock $modal itself. This makes perfect sense. I had been battling with this for ages and can now see a way forward. Appreciate it.
-
Brant about 10 yearsYou're welcome, I'm happy it worked for you. Hopefully UI Bootstrap will provide a default $modal mock that we can use in the future.
-
lightalex almost 10 yearsInstead of having
$scope.selected = selectedItem;
in the result of the modal, I have a service callSessionService.set('lang', selectedItem);
. Is it possible to test if the service has been called right afterscope.modalInstance.close('FR');
? -
Brant almost 10 years@lightalex You can use a Jasmine spy on the 'set' function of your service, and then expect it to have been called. Similar to this:
spyOn( SessionService, 'set' ).andCallThrough(); scope.modalInstance.close('FR'); expect( SessionService.set ).toHaveBeenCalled();
-
Matt Kim about 9 yearsHaving some trouble on this. My scope.modalInstance is undefined..are you guys are getting a valid modalInstance?
-
Brant about 9 years@MattKim Yes, scope.modalInstance was defined and working in my test.
-
Scott over 8 yearsThis test works great, any ideas for testing the ModalInstanceController? I have scope functions within the instance controller so I wanted to test them, but am getting an error when attempting to inject
$modalInstance
. -
Lawrence I. Siden over 8 yearsInstead of mocking the modal instance, I'd like to get a real modal instance opened with my template, then get a reference to the modal DOM element. Is there a way to do that?
-
BitfulByte about 8 yearsThis works for me and is the best way to mock promises
-
Parth Shah almost 8 yearsI don't see $rootScope defined in FakeModal, so how is it accessible in close and dimiss functions? Sorry, I am new to Angular and Jasmine and I feel it has something to do with scope inheritance but I can't see how FakeModal gets it.
-
ruffin over 3 yearsI think you're missing a - in your arrow functions, so to speak, that would make
->
into=>
, right? And I think what you're adding is that he could replaceclose: function( item ) { this.result.confirmCallBack( item ); },
withclose: this.result.confirmCallBack,
, right? I'm not real sure what the code as written is doing in the first snippet. (☉_☉)