Mocking $ modal nos testes unitários do AngularJS

Estou escrevendo um teste de unidade para um controlador que aciona um $modal e usa a promise retornada para executar alguma lógica. Eu posso testar o controlador pai que triggers o $ modal, mas não posso para a vida de mim descobrir como zombar de uma promise bem-sucedida.

Eu tentei várias maneiras, inclusive usando $q e $scope.$apply() para forçar a resolução da promise. No entanto, o mais próximo que obtive é juntar algo semelhante à última resposta neste post do SO;

Já vi isso algumas vezes com o “antigo” $dialog modal de $dialog . Não consigo encontrar muito sobre como fazer isso com o “novo” $dialog modal.

Alguns pointers seriam apreciados.

Para ilustrar o problema, estou usando o exemplo fornecido nos documentos da UI Bootstrap , com algumas pequenas edições.

Controladores (principal e 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'); }; }); 

A visão (main.html)

 
Selection from a modal: {{ selected }}

O teste

 '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. }); }); 

Quando você espiona a function $ modal.open no beforeEach,

 spyOn($modal, 'open').andReturn(fakeModal); or spyOn($modal, 'open').and.returnValue(fakeModal); //For Jasmine 2.0+ 

você precisa retornar uma simulação do que $ modal.open normalmente retorna, não uma simulação de $ modal, que não inclui uma function open como você definiu em sua fakeModal falsa. O falso modal deve ter um object de result que contenha uma function para armazenar os retornos de chamada (a ser chamado quando os botões OK ou Cancelar forem clicados). Ele também precisa de uma function de close (simulando um botão OK, clique no modal) e uma function de dismiss (simulando um botão Cancelar, clique no modal). As funções close e dismiss chamam as funções de retorno necessárias quando chamadas.

Altere o fakeModal para o seguinte e o teste de unidade será aprovado:

 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 ); } }; 

Além disso, você pode testar o caso de diálogo de cancelamento adicionando uma propriedade para testar no manipulador de cancelamento, neste caso $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()); }); 

Uma vez que o sinalizador de cancelamento é definido, o teste de unidade será algo como isto:

 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 ); }); 

Para adicionar à resposta de Brant, aqui está um mock ligeiramente melhorado que permite lidar com alguns outros cenários.

 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(); } }; 

Isso permitirá que o mock manipule situações em que …

Você usa o modal com os estilos de manipulador .catch() .then() , .catch() e .catch() passando duas funções ( successCallback, errorCallback ) para successCallback, errorCallback .then() , por exemplo:

 modalInstance .result .then(function () { // close hander }) .catch(function () { // dismiss handler }) .finally(function () { // finally handler }); 

Como os modais usam promises, você deve definitivamente usar $ q para essas coisas.

Código se torna:

 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 ); }); 

A resposta de Brant foi claramente incrível, mas essa mudança tornou ainda melhor para mim:

  fakeModal = opened: then: (openedCallback) -> openedCallback() result: finally: (callback) -> finallyCallback = callback 

então na área de teste:

  finallyCallback() expect (thing finally callback does) .toEqual (what you would expect)