Mocking $modal в модульных тестах AngularJS

Я пишу unit test для контроллера, который запускает $modal и использует обещание, возвращаемое для выполнения некоторой логики. Я могу проверить родительский контроллер, который запускает $modal, но я не могу на всю жизнь понять, как издеваться над успешным обещанием.

Я пробовал несколько способов, включая использование $q и $scope.$apply(), чтобы заставить разрешение обещания. Тем не менее, самое близкое, что я получил, это собрать что-то похожее на последний ответ в этом в сообщении SO;

Я видел это несколько раз с "старым" $dialog модальным. Я не могу много узнать о том, как это сделать с помощью "нового" $dialog модального.

Некоторые указатели будут оценены.

Чтобы проиллюстрировать проблему, я использую пример, представленный в документах UT Bootstrap, с некоторыми незначительными изменениями.

Контроллеры (основной и модальный)

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

Вид (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>

Тест

'use strict';

describe('Controller: MainCtrl', function() {

    // load the controller 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.
    });
});

Ответ 1

Когда вы просматриваете функцию $modal.open в beforeEach,

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

or 

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

вам нужно вернуть макет того, что обычно возвращает $modal.open, а не макет из $modal, который не включает функцию open, как вы выложили в макете fakeModal. Поддельный модальный должен иметь объект result, который содержит функцию then для хранения обратных вызовов (вызывается при нажатии на кнопки "ОК" или "Отмена" ). Ему также нужна функция close (имитация кнопки ОК, нажимающей на модальную) и функцию dismiss (имитация кнопки "Отмена" на модальном). Функции close и dismiss вызывают необходимые функции обратного вызова при вызове.

Измените fakeModal на следующее и unit test будет проходить:

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

Кроме того, вы можете протестировать диалоговое окно отмены, добавив свойство для проверки в обработчике отмены, в этом случае $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());
});

Как только флаг отмены установлен, unit test будет выглядеть примерно так:

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

Ответ 2

Чтобы добавить к ответу Бранта, вот немного улучшенный макет, который позволит вам обрабатывать некоторые другие сценарии.

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

Это позволит макету обрабатывать ситуации, в которых...

Вы используете модальный стиль .then(), .catch() и .finally() вместо двух функций (successCallback, errorCallback) для .then(), например:

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

Ответ 3

Так как modals используют promises, вы обязательно должны использовать $q для таких вещей.

Код становится:

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

Ответ 4

Ответ Бранта был явно потрясающим, но это изменение сделало его еще лучше для меня:

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

затем в тестовой области:

  finallyCallback()

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