Статьи

Угловое обучение: модульное тестирование $ watch выражений

Сегодня я хотел написать модульный тест для выражения часов на моем контроллере. То, что сначала казалось совершенно очевидным, оказалось довольно неприятным. В любом случае, спасибо замечательному члену сообщества Angular IRC, я смог быстро решить эту проблему. Итак, вот история.

Эта статья является частью моей серии «Learning NG», в которой представлены некоторые мои приключения во время изучения Angular. Проверьте  вступление серии и другие статьи . Обратите внимание, я новичок в Angular, поэтому я более чем рад любым отзывам и предложениям по улучшению от более опытных людей, чем я.

Фон

Новые лучшие практики Angular предлагают использовать — то, что они называют — «контроллер как» синтаксис. Таким образом, вместо записи контроллера, как

module.controller('MainCtrl', function($scope){
    $scope.someScopeVariable = 'Hello, world!';
});

Вы должны вместо этого написать это так.

module.controller('MainCtrl', function(){
    var vm = this; // this is a best practice approach
    vm.someScopeVariable = 'Hello, world!';
});

На стороне HTML вы обычно включаете контроллер, используя подобный синтаксис.

<div ng-controller="MainCtrl as vm">
    {{ vm.someScopeVariable }}
</div>

проблема

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

app.controller('MainCtrl', function($scope) {
  var vm = this;
  var previousSelection = null;

  vm.currentSelection = null;

  $scope.$watch('vm.currentSelection', function(newVal, oldVal){
    // we'd like to test THIS LINE HERE
    previousSelection = oldVal;
  });

  vm.changeSelection = function(shouldRevert){
    if(shouldRevert){
      vm.currentSelection = previousSelection;
    }
  };
});

Обратите внимание, что я делаю инъекцию, из-за  $scope которой может показаться, что я использую синтаксис контроллера $ scope. На самом деле это для возможности регистрации $watch.
Кроме того, выше приведен простой демонстрационный пример, который, слегка измененный, может быть полезен, например, для отмены выбора пользователя в раскрывающемся меню  ng-change.

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

describe('Testing $watch expressions', function() {
  var $scope = null;
  var ctrl = null;

  //you need to indicate your module in a test
  beforeEach(module('plunker'));

  describe('using the controller as syntax', function() {

    beforeEach(inject(function($rootScope, $controller) {
      $scope = $rootScope.$new();

      ctrl = $controller('MainCtrl', {
        $scope: $scope
      });

    }));

    it('test using $digest', function() {
      // make an initial selection
      ctrl.currentSelection = 'Hi';
      $scope.$digest();

      // make another one
      ctrl.currentSelection = 'New';
      $scope.$digest();

      // simulate a ng-change which should revert to the previous value
      ctrl.changeSelection(true);

      expect(ctrl.currentSelection).toEqual('Hi');
    });

  });

});

Обратите внимание, что я использую  $scope.$digest() после установки  currentSelectionна контроллере. Это необходимо для запуска «цикла дайджеста», который вызывает  $watch выражение, которое я определил. К сожалению, это не работает! Выражение watch вызывается, но  newVal и  oldVal оба не определены.

Вместо этого, если я вернул свой контроллер к «старому» синтаксису $ scope …

app.controller('MainCtrl', function($scope) {
  var previousSelection = null;

  $scope.currentSelection = null;

  $scope.$watch('currentSelection', function(newVal, oldVal){
    previousSelection = oldVal;
  });

  $scope.changeSelection = function(shouldRevert){
    if(shouldRevert){
      $scope.currentSelection = previousSelection;
    }
  };
});

… и скорректировал мои тесты соответственно:

it('test using $digest', function() {
    // make an initial selection
    $scope.currentSelection = 'Hi';
    $scope.$digest();

    // make another one
    $scope.currentSelection = 'New';
    $scope.$digest();

    // simulate a ng-change which should revert to the previous value
    $scope.changeSelection(true);

    expect($scope.currentSelection).toEqual('Hi');
});

..то было вызвано выражение $ watch с правильным значением, и тесты прошли, как и ожидалось.

В качестве альтернативы, я мог бы оставить синтаксис «контроллер как» ранее, и вместо вызова  $scope.$digest() в моих тестах, вызовите  $scope.$apply('...'):

it('test using $scope.$apply(...)', function() {
    // make an initial selection
    $scope.$apply('vm.currentSelection="Hi"');

    // make another one
    $scope.$apply('vm.currentSelection="New"');

    // simulate a ng-change which should revert to the previous value
    ctrl.changeSelection(true);

    expect(ctrl.currentSelection).toEqual('Hi');
});

Это тоже сработало. Что тут не так ??

Я разместил на канале  IRC ..

[14:32:52]  Interesting, when unit testing $watch expressions it makes a difference whether you used the "controller as" syntax or not. http://plnkr.co/edit/MVOgfmXVG1MzUg6nfM6W?p=preview
[14:34:01]  of course - watch expressions watch on the scope.
[14:36:46]  sacho: yep, but by executing $scope.$digest() in the tests I'd expect that the $watch expression is executed...which, btw it is, but not with the correct values
[14:37:17]  sacho: Instead, it seems that in that case you have to do something like $scope.$apply('someScopeVar = "some new value"');
[14:37:26]  then it fires as well, but with the passed new value
[14:37:35]  that's kinda odd..
[14:38:09]  while, when using the $scope syntax, I can simply call $scope.$digest() and everything works as expected...
[14:38:13]  huh?
...
[14:46:08]  juristr, well, you're not placing the controller on the scope, anywhere.
[14:46:26]  so you're not using controllerAs.

Ой..! Проблема в  beforeEach. Пока я предполагал, что следующие строки подключают контроллер к $scope

beforeEach(inject(function($rootScope, $controller) {
    $scope = $rootScope.$new();

    ctrl = $controller('MainCtrl', {
        $scope: $scope
    });
}));

… что они делают … но контроллер / область не привязаны к  vmсвойству, которое  $watch ожидает выражение …

Таким образом, переходя на …

beforeEach(inject(function($rootScope, $controller) {
    $scope = $rootScope.$new();

    ctrl = $controller('MainCtrl', {
        $scope: $scope
    });

    // THIS was missing
    $scope.vm = ctrl;
}));

.. делает все работает как положено, даже при использовании  $scope.$digest().

Вы можете поиграть с этим самостоятельно в этом Plunkr.

Вывод

На самом деле это довольно сложно и легко ошибиться, особенно когда вы смотрите на тестовые примеры, которые работают с кодом, который использует несколько более старый «синтаксис области видимости». Я еще не уверен, что обернул голову вокруг этой проблемы еще … если у меня будет лучшее объяснение, я обновлю почту …

Подвести итоги:

  • использование $scope.$apply('theScopeVariable = "new value"')
  • обратите внимание на инициализацию контроллера в вашем модуле тестирования. Если вы используете контроллер в качестве синтаксиса, убедитесь, что вы установили его соответствующим образом (см. Пример раньше).