Как протестировать Angular $modal с помощью Sinon.js?

Я пытаюсь написать модульные тесты для $modal в AngularJS. Код модального окна находится в контроллере следующим образом:

$scope.showProfile = function(user){
                var modalInstance = $modal.open({
                templateUrl:"components/profile/profile.html",
                resolve:{
                    user:function(){return user;}
                },
                controller:function($scope,$modalInstance,user){$scope.user=user;}
            });
        };

Функция вызывается на кнопке в ng-repeat в HTML следующим образом:

 <button class='btn btn-info' showProfile(user)'>See Profile</button>

Как вы можете видеть, пользователь передается и используется в модальном окне, данные затем привязываются к частичному профилю в его HTML.

Я использую Karma-Mocha вместе с Karma-Sinon, чтобы попытаться выполнить модульные тесты, но я не могу понять, как этого добиться, я хочу убедиться, что пользователь, которого передают, тот же, что используется в параметре разрешения модального окна.

Я видел несколько примеров того, как это сделать с помощью Jasmine, но мне не удалось преобразовать их в тесты mocha + sinon.

Вот моя попытка:

Код установки:

describe('Unit: ProfileController Test Suite,', function(){
beforeEach(module('myApp'));

var $controller, modalSpy, modal, fakeModal;

fakeModal  = {// Create a mock object using spies
    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);
    }
};

var modalOptions = {
    templateUrl:"components/profile/profile.html",
    resolve:{
        agent:sinon.match.any //No idea if this is correct, trying to match jasmine.any(Function)
    },
    controller:function($scope,$modalInstance,user){$scope.user=user;}
};

var actualOptions;

beforeEach(inject(function(_$controller_, _$modal_){
    // The injector unwraps the underscores (_) from around the parameter names when matching
    $controller = _$controller_;
    modal = _$modal_;
    modalSpy = sinon.stub(modal, "open");
    modalSpy.yield(function(options){ //Doesn't seem to be correct, trying to match Jasmines callFake function but get this error - open cannot yield since it was not yet invoked.
        actualOptions = options;
        return fakeModal;
    });
}));

var $scope, controller;

beforeEach(function() {
    $scope = {};

    controller = $controller('profileController', {
        $scope: $scope,
        $modal: modal
    });

});

afterEach(function () {
    modal.open.restore();
});

Собственно тест:

describe.only('display a user profile', function () {
        it('user details should match those passed in', function(){
            var user= { name : "test"};
            $scope.showProfile(user);

            expect(modalSpy.open.calledWith(modalOptions)).to.equal(true); //Always called with empty
            expect(modalSpy.open.resolve.user()).to.equal(user); //undefined error - cannot read property resolve of undefined
        });
    });

Моя тестовая установка и фактический тест основаны на коде Jasmine, с которым я столкнулся, и пытаюсь преобразовать его в код Mocha + SinonJS, я новичок как в AngularJS, так и в написании модульных тестов, поэтому я надеюсь, что мне просто нужен толчок в правильном направлении. .

Может ли кто-нибудь поделиться правильным подходом при использовании Mocha + SinonJS вместо Jasmine?


person Donal Rafferty    schedule 17.07.2015    source источник


Ответы (1)


Это будет длинный ответ, касающийся модульного тестирования, заглушек и sinon.js (в некоторой степени).

(Если вы хотите пропустить этот шаг, прокрутите вниз до заголовка №3 и посмотрите на окончательную реализацию вашей спецификации)

1. Установите цель

Я хочу убедиться, что пользователь, которого передают, тот же, что и в параметре разрешения модального окна.

Отлично, значит, у нас есть цель.

Ожидается, что возвращаемое значение resolve { user: fn } $modal.open будет пользователем, которого мы передали в метод $scope.showProfile.

Учитывая, что $modal является внешней зависимостью в вашей реализации, нас просто не волнует внутренняя реализация $modal. Очевидно, что мы не хотим внедрять настоящий сервис $modal в наш набор тестов.

Посмотрев на свой набор тестов, вы, кажется, уже поняли это (милый!), поэтому нам не придется касаться причин, лежащих в основе этого слишком.

Я полагаю, что первоначальная формулировка ожидания будет выглядеть примерно так:

Должен был быть вызван $modal.open, а его функция resolve.user должна вернуть пользователя, переданного в $scope.showProfile.

2. Подготовка

Сейчас я собираюсь вырезать многое из вашего набора тестов, чтобы сделать его немного более читабельным. Приношу свои извинения, если отсутствуют какие-либо детали, жизненно важные для прохождения спецификации.

перед каждым

Я бы начал с упрощения блока beforeEach. Гораздо чище иметь один блок beforeEach для каждого блока описания, это упрощает читаемость и уменьшает шаблонный код.

Ваш упрощенный блок beforeEach может выглядеть примерно так:

var $scope, $modal, createController; // [1]: createController(?)

beforeEach(function () {
  $modal = {}; // [2]: empty object? 

  module('myApp', function ($provide) {
    $provide.value('$modal', $modal); // [3]: uh? 
  });

  inject(function ($controller, $injector) { // [4]: $injector? 
    $scope = $injector.get('$rootScope').$new();
    $modal = $injector.get('$modal');

    createController = function () { // [5(1)]: createController?!
      return $controller('profileController', {
        $scope: $scope
        $modal: $modal
      });
    };
  });

  // Mock API's
  $modal.open = sinon.stub(); // [6]: sinon.stub()? 
});

Итак, некоторые заметки о том, что я добавил/изменил:

[1]: createController — это то, что мы установили в моей компании уже довольно давно, когда писали модульные тесты для контроллеров angular. Это дает вам большую гибкость в изменении указанных зависимостей контроллеров для каждой спецификации.

Предположим, что в реализации контроллера у вас было следующее:

.controller('...', function (someDependency) {
  if (!someDependency) {
    throw new Error('My super important dependency is missing!');  
  }

  someDependency.doSomething();
});

Если вы хотели написать тест для throw, но отказались от метода createController, вам нужно настроить отдельный блок describe с собственным вызовом beforeEach|before для установки someDependency = undefined. Большая проблема!

С "отложенным $inject" это так же просто, как:

it('throws', function () {
  someDependency = undefined;

  function fn () {
    createController();
  }

  expect(fn).to.throw(/dependency missing/i);
});

[2]: пустой объект. Заменяя глобальную переменную пустым объектом в начале вашего блока beforeEach, мы можем быть уверены, что все оставшиеся методы из предыдущей спецификации умерли. сильный>


[3]: $предоставить с помощью $providing смоделированного (на данный момент пустого) объекта в качестве значения для нашего module нам не нужно загружать модуль, содержащий реальную реализацию $modal.

По сути, это делает модульное тестирование углового кода легким делом, так как вы никогда снова не столкнетесь с Error: $injector:unpr Unknown Provider в своих модульных тестах, просто убив все без исключения ссылки на un- интересный код для гибкого, сфокусированного модульного теста.


[4]: $injector Я предпочитаю использовать $injector, так как он уменьшает количество аргументов, которые необходимо передать методу inject(), практически до нуля. Делай здесь, что хочешь!


[5]: createController Прочтите №1.


[6]: sinon.stub В конце вашего блока beforeEach я бы посоветовал вам снабдить все ваши заглушенные зависимости необходимыми методами. Заглушенные методы.

Если вы уверены, что метод-заглушка должен всегда возвращаться, скажем, разрешенное обещание — вы можете изменить эту строку на:

dependency.mockedFn = sinon.stub().returns($q.when());
// dont forget to expose, and $inject -> $q!

Но в целом я бы рекомендовал явные операторы возврата в отдельных it().

3. Написание спецификации

Итак, вернемся к рассматриваемой проблеме.

Учитывая вышеупомянутый блок beforeEach, ваш describe/it может выглядеть примерно так:

describe('displaying a user profile', function () {
  it('matches the passed in user details', function () {
    createController();
  });
});

Казалось бы, нам нужно следующее:

  • Пользовательский объект.
  • Звонок $scope.showProfile.
  • Ожидание возвращаемого значения функции разрешения вызванного $modal.open.

Проблема заключается в том, что мы тестируем что-то, что не в наших руках. То, что $modal.open() делает за кулисами, не входит в набор спецификаций для вашего контроллера - это зависимость, а зависимости заглушаются.

Однако мы можем проверить, вызывал ли наш контроллер $modal.open с правильными параметрами, но связь между resolve и controller не является частью этого набора спецификаций (подробнее об этом позже).

Итак, чтобы пересмотреть наши потребности:

  • Пользовательский объект.
  • Звонок $scope.showProfile.
  • Ожидание от параметров, переданных в $modal.open.

it('calls $modal.open with the correct params', function () {
  // Preparation
  var user = { name: 'test' };
  var expected = {
    templateUrl: 'components/profile/profile.html',
    resolve: {
      user: sinon.match(function (value) {
        return value() === user;
      }, 'boo!')
    },
    controller: sinon.match.any        
  };

  // Execution
  createController();
  $scope.showProfile(user);

  // Expectation
  expect($modal.open).to.have
    .been.calledOnce
    .and.calledWithMatch(expected);
});

Я хочу убедиться, что пользователь, которого передают, тот же, что и в параметре разрешения модального окна.

"Должен быть создан экземпляр $modal.open, а его функция resolve.user должна возвращать пользователя, переданного в $scope.showProfile."

Я бы сказал, что наша спецификация покрывает именно это — и мы «отменили» $modal для загрузки. Мило.

Объяснение пользовательских сопоставителей, взятое из документы sinonjs.

Пользовательские сопоставители создаются с помощью фабрики sinon.match, которая принимает тестовую функцию и необязательное сообщение. Тестовая функция принимает значение в качестве единственного аргумента, возвращает true, если значение соответствует ожидаемому, и false в противном случае. Строка сообщения используется для генерации сообщения об ошибке в случае, если значение не соответствует ожидаемому.

По сути;

sinon.match(function (value) {
  return /* expectation on the behaviour/nature of value */
}, 'optional_message');

Если вы абсолютно хотите проверить возвращаемое значение resolve (значение, которое заканчивается в $modal controller), я бы посоветовал вам протестировать контроллер изолированно, извлекая его в именованный контроллер, а не в анонимную функцию.

$modal.open({
  // controller: function () {},
  controller: 'NamedModalController'
});

Таким образом, вы можете написать ожидания для модального контроллера (конечно, в другом файле спецификаций) как таковые:

it('exposes the resolved {user} value onto $scope', function () {
  user = { name: 'Mike' };
  createController();
  expect($scope).to.have.property('user').that.deep.equals(user);
});

Так вот, многое из этого было повторением — вы уже делаете многое из того, что я затронул, надеюсь, я не выйду из роли инструмента.

Некоторые подготовительные данные в it(), который я предложил, можно было бы переместить в блок beforeEach, но я бы посоветовал делать это только тогда, когда есть множество тестов, вызывающих один и тот же код.

Сохранение набора спецификаций СУХИМ не так важно, как сохранение ваших спецификаций в явном виде, чтобы избежать путаницы, когда другой разработчик придет, чтобы прочитать их и исправить некоторые регрессионные ошибки.


Чтобы завершить, некоторые из встроенных комментариев, которые вы написали в своем оригинале:

Синон.матч.любой

var modalOptions = {
  resolve:{
    agent:sinon.match.any // No idea if this is correct, trying to match jasmine.any(Function)
  },
};

Если вы хотите сопоставить его с функцией, вы должны сделать:

sinon.match.func, что эквивалентно jasmine.any(Function).

sinon.match.any соответствует всему.


sinon.stub.yield([arg1, arg2])

// open cannot yield since it was not yet invoked.
modalSpy.yield(function(options){ 
  actualOptions = options;
  return fakeModal;
});

Прежде всего, у вас есть несколько методов на $modal, которые (или должны быть) заглушены. Таким образом, я думаю, что маскировать $modal.open под modalSpy - плохая идея - не очень ясно, какой метод для yield.

Во-вторых, вы смешиваете spy с stub (я делаю это все время...), когда ссылаетесь на свою заглушку как modalSpy.

spy оборачивает исходную функциональность и оставляет ее, записывая все «события» для предстоящих ожиданий, и на этом все.

stub фактически является spy, с той разницей, что мы можем изменить поведение указанной функции, указав .returns(), .throws() и т. д. Короче говоря; сочный шпион.

Как следует из сообщения об ошибке, функция не может yield пока не будет вызвана.

  it('yield / yields', function () {
    var stub = sinon.stub();

    stub.yield('throwing errors!'); // will crash...
    stub.yields('y');

    stub(function () {
      console.log(arguments);
    });

    stub.yield('x');
    stub.yields('ohno'); // wont happen...
  });

Если бы мы удалили строку stub.yield('throwing errors!'); из этой спецификации, вывод выглядел бы так:

LOG: Object{0: 'y'}
LOG: Object{0: 'x'}

Коротко и приятно (это примерно все, что я знаю о урожайности/урожайности);

  • yield после вызова вашего обратного вызова-заглушки/шпиона.
  • yields перед вызовом вашего обратного вызова-заглушки/шпиона.

Если вы зашли так далеко, вы, вероятно, поняли, что я могу часами болтать на эту тему. К счастью, я устаю, и пришло время закрыть глаза.


Некоторые ресурсы, слабо относящиеся к теме:

person Kasper Lewau    schedule 17.07.2015
comment
Отличный ответ. Я раньше не видел/не использовал sinon.match, и вы хорошо это объясняете. - person Matt Herbstritt; 18.07.2015
comment
Отличный пост, большое спасибо за подробное объяснение - person Donal Rafferty; 18.07.2015
comment
Я рад, что вам понравилось - и что я не утомил вас до смерти (затрагивая крайность) количеством текста. - person Kasper Lewau; 18.07.2015
comment
Спасибо, очень подробное объяснение и приятно видеть, что некоторые вещи, которые я делаю в целом, также делают другие. - person jaseeey; 12.07.2016