web-dev-qa-db-fra.com

Mocking $ modal dans les tests unitaires AngularJS

J'écris un test unitaire pour un contrôleur qui déclenche un $modal Et utilise la promesse retournée pour exécuter une certaine logique. Je peux tester le contrôleur parent qui déclenche le modal $, mais je ne peux pas me débrouiller seul pour savoir comment se moquer d'une promesse réussie.

J'ai essayé de nombreuses manières, notamment en utilisant $q Et $scope.$apply() pour forcer la résolution de la promesse. Cependant, le plus proche que j'ai eu est de rassembler quelque chose de similaire à la dernière réponse de this SO post;

J'ai déjà vu cette requête à quelques reprises avec le "vieux" modal $dialog. Je ne trouve pas grand chose à faire avec le "nouveau" modal $dialog.

Quelques pointeurs seraient très appréciés.

Pour illustrer le problème, j'utilise le exemple fourni dans l'interface utilisateur Bootstrap docs =, avec quelques modifications mineures.

Contrôleurs (principaux et modaux)

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

La vue (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>

Le 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.
    });
});
66
coderigo

Lorsque vous espionnez la fonction $ modal.open dans beforeEach,

spyOn($modal, 'open').andReturn(fakeModal);

or 

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

vous devez renvoyer une maquette de ce que $ modal.open retourne normalement, pas une maquette de $ modal, qui n'inclut pas de fonction open telle que définie dans votre maquette fakeModal. Le faux modal doit avoir un objet result contenant une fonction then pour stocker les rappels (à appeler lorsque les boutons OK ou Annuler sont cliqués). Il faut également une fonction close (simulant un clic sur le bouton OK sur le modal) et une fonction dismiss (simulant un clic sur le bouton Annuler). Les fonctions close et dismiss appellent les fonctions de rappel nécessaires lorsqu'elles sont appelées.

Modifiez le fakeModal comme suit et le test unitaire réussira:

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

En outre, vous pouvez tester la casse de la boîte de dialogue d'annulation en ajoutant une propriété à tester dans le gestionnaire d'annulation, dans ce cas, $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());
});

Une fois que le drapeau d'annulation est défini, le test unitaire ressemblera à ceci:

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

Pour ajouter à la réponse de Brant, voici une maquette légèrement améliorée qui vous permettra de gérer d’autres scénarios.

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

Cela permettra à la maquette de gérer des situations où ...

Vous utilisez le modal avec le style de gestionnaire .then(), .catch() et .finally() au lieu de passer 2 fonctions (successCallback, errorCallback) À un .then(), par exemple:

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

Puisque modaux utilise les promesses, vous devez absolument utiliser $ q pour de telles choses.

Le code devient:

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

La réponse de Brant était clairement géniale, mais ce changement l'a rendu encore meilleur pour moi:

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

puis dans la zone de test:

  finallyCallback()

  expect (thing finally callback does)
    .toEqual (what you would expect)
2
Kim Miller