Spec-Zone.ru › Angular.js 1.2

JavaScript — это язык с динамической типизацией, который обладает большой выразительностью, но при этом практически не получает помощи от компилятора. По этой причине мы считаем крайне важным, чтобы любой код на JavaScript сопровождался надежным набором тестов. Мы добавили много функций в Angular, которые упрощают тестирование приложений Angular. Поэтому нет оправдания для отсутствия тестирования.

Улучшить эту документациюРазделение забот

Тестирование модулей, как следует из названия, заключается в тестировании отдельных блоков кода. Тесты модулей пытаются ответить на вопросы, такие как: «Правильно ли я подумал о логике?» или «Функция сортировки упорядочивает список в правильном порядке?»

Для ответа на такие вопросы очень важно, чтобы мы могли изолировать тестируемый блок кода. Это связано с тем, что при тестировании функции сортировки мы не хотим быть вынужденными создавать связанные фрагменты, такие как элементы DOM, или выполнять вызовы XHR для получения данных для сортировки.

Хотя это может показаться очевидным, на типичном проекте может быть очень сложно вызвать отдельную функцию. Причина в том, что разработчики часто смешивают различные задачи, что приводит к блоку кода, который делает всё. Он отправляет запрос XHR, сортирует данные ответа и затем манипулирует DOM.

В Angular мы стараемся сделать правильные действия лёгкими, поэтому предоставляем инъекцию зависимостей для XHR (которую вы можете смоделировать) и создаём абстракции, позволяющие сортировать вашу модель, не прибегая к манипуляциям с DOM. В итоге легко написать функцию сортировки, которая сортирует данные, чтобы ваш тест мог создать набор данных, применить функцию и убедиться, что полученная модель отсортирована в правильном порядке. Тест не должен ждать получения ответа XHR, создавать нужный вид DOM или проверять, что ваша функция изменила DOM правильным образом.

С великой силой приходит великая ответственность

Angular разработан с учётом возможности тестирования, но всё равно требует, чтобы вы делали правильные вещи. Мы постарались сделать правильные вещи лёгкими, но если вы игнорируете эти рекомендации, вы можете получить не тестируемое приложение.

Инъекция зависимостей

Существует несколько способов получения зависимости. Вы можете:

  1. Создать её, используя оператор new.
  2. Найти её в известном месте, также известном как глобальный синглтон.
  3. Запросить её в реестре (также известном как реестр служб). (Но как получить доступ к реестру? Скорее всего, посмотрев его в известном месте. См. п. 2.)
  4. Ожидать, что её передадут вам.

Из четырёх вариантов в списке выше только последний является тестируемым. Давайте посмотрим, почему:

Использование оператора new

Хотя с оператором new фундаментально всё в порядке, проблема возникает при вызове new конструктора. Это постоянно связывает место вызова с типом. Например, предположим, что мы пытаемся создать экземпляр XHR, который будет получать данные с сервера.

function MyClass() {
this.doWork = function() {
  var xhr = new XHR();
  xhr.open(method, url, true);
  xhr.onreadystatechange = function() {...}
  xhr.send();
}
}

Проблема возникает в тестах, когда мы хотим создать экземпляр MockXHR, который позволит нам возвращать фиктивные данные и моделировать сетевые ошибки. Вызывая new XHR(), мы постоянно привязаны к фактическому XHR, и нет способа его заменить. Да, мы могли бы использовать monkey patching, но это плохая идея по многим причинам, которые выходят за рамки этой документации.

Вот пример того, как вышеуказанный класс становится трудно тестируемым при использовании monkey patching:

var oldXHR = XHR;
XHR = function MockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// assert that MockXHR got called with the right arguments
XHR = oldXHR; // if you forget this bad things will happen

Глобальный поиск:

Другой способ решения проблемы — искать службу в известном месте.

function MyClass() {
this.doWork = function() {
  global.xhr({
    method:'...',
    url:'...',
    complete:function(response){ ... }
  })
}
}

Хотя новый экземпляр зависимости не создаётся, это по сути то же самое, что и new, в том смысле, что нет способа перехватить вызов global.xhr для целей тестирования, кроме использования monkey patching. Основная проблема для тестирования заключается в том, что глобальная переменная должна быть изменена, чтобы заменить её вызовом на метод-заглушку. Для дополнительного объяснения, почему это плохо, см.: Brittle Global State & Singletons

Приведённый выше класс трудно тестировать, поскольку нам нужно изменить глобальное состояние:

var oldXHR = global.xhr;
global.xhr = function mockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// assert that mockXHR got called with the right arguments
global.xhr = oldXHR; // if you forget this bad things will happen

Реестр служб:

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

function MyClass() {
var serviceRegistry = ????;
this.doWork = function() {
  var xhr = serviceRegistry.get('xhr');
  xhr({
    method:'...',
    url:'...',
    complete:function(response){ ... }
  })
}

Однако откуда берётся serviceRegistry? Если оно:

  • new-инициализировано, у теста нет возможности сбросить службы для тестирования.
  • представляет собой глобальный поиск, то возвращаемая служба также является глобальной (но сброс проще, так как существует только одна глобальная переменная, которую нужно сбросить).

Приведённый выше класс трудно тестировать, поскольку нам нужно изменить глобальное состояние:

var oldServiceLocator = global.serviceLocator;
global.serviceLocator.set('xhr', function mockXHR() {});
var myClass = new MyClass();
myClass.doWork();
// assert that mockXHR got called with the right arguments
global.serviceLocator = oldServiceLocator; // if you forget this bad things will happen

Передача зависимостей:

Наконец, зависимость может быть передана.

function MyClass(xhr) {
this.doWork = function() {
  xhr({
    method:'...',
    url:'...',
    complete:function(response){ ... }
  })
}

Это предпочтительный метод, поскольку код не делает предположений о происхождении xhr и вместо этого заботится о том, кто создал класс, ответственный за его передачу. Поскольку создатель класса должен быть другим кодом, чем пользователь класса, это разделяет ответственность за создание и логику. Это инъекция зависимостей вкратце.

Приведённый выше класс тестируем, так как в тесте мы можем написать:

function xhrMock(args) {...}
var myClass = new MyClass(xhrMock);
myClass.doWork();
// assert that xhrMock got called with the right arguments

Обратите внимание, что при написании этого теста глобальные переменные не пострадали.

Angular предоставляет встроенную инъекцию зависимостей, делая правильные вещи лёгкими, но вам всё равно нужно это сделать, если вы хотите воспользоваться преимуществами тестируемости.

Контроллеры

Уникальность каждого приложения заключается в его логике, и именно логику мы хотим проверить. Если логика вашего приложения содержит манипуляции с DOM, её будет сложно протестировать. См. пример ниже:

function PasswordCtrl() {
// get references to DOM elements
var msg = $('.ex1 span');
var input = $('.ex1 input');
var strength;

this.grade = function() {
  msg.removeClass(strength);
  var pwd = input.val();
  password.text(pwd);
  if (pwd.length > 8) {
    strength = 'strong';
  } else if (pwd.length > 3) {
    strength = 'medium';
  } else {
    strength = 'weak';
  }
  msg
   .addClass(strength)
   .text(strength);
}
}

Приведённый выше код проблематичен с точки зрения тестируемости, поскольку требует, чтобы в момент выполнения кода у вашего теста был присутствующий нужный вид DOM. Тест будет выглядеть так:

var input = $('<input type="text"/>');
var span = $('<span>');
$('body').html('<div class="ex1">')
  .find('div')
    .append(input)
    .append(span);
var pc = new PasswordCtrl();
input.val('abc');
pc.grade();
expect(span.text()).toEqual('weak');
$('body').empty();

В Angular контроллеры строго отделены от логики манипулирования DOM, что приводит к гораздо более лёгкой тестируемости, как показано в следующем примере:

function PasswordCtrl($scope) {
$scope.password = '';
$scope.grade = function() {
  var size = $scope.password.length;
  if (size > 8) {
    $scope.strength = 'strong';
  } else if (size > 3) {
    $scope.strength = 'medium';
  } else {
    $scope.strength = 'weak';
  }
};
}

и тест прост:

var $scope = {};
var pc = $controller('PasswordCtrl', { $scope: $scope });
$scope.password = 'abc';
$scope.grade();
expect($scope.strength).toEqual('weak');

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

Фильтры

Фильтры — это функции, преобразующие данные в удобочитаемый для пользователя формат. Они важны, поскольку снимают с логики приложения ответственность за форматирование, что ещё больше упрощает логику приложения.

myModule.filter('length', function() {
return function(text) {
  return ('' + (text || '')).length;
}
});

var length = $filter('length');
expect(length(null)).toEqual(0);
expect(length('abc')).toEqual(3);

Директивы

Директивы в Angular отвечают за инкапсуляцию сложных функций внутри пользовательских HTML-тегов, атрибутов, классов или комментариев. Тесты модулей очень важны для директив, поскольку компоненты, созданные с помощью директив, могут использоваться во всём приложении и во многих различных контекстах.

Директива простого HTML-элемента

Начнём с приложения Angular без зависимостей.

var app = angular.module('myApp', []);

Теперь можем добавить директиву в наше приложение.

app.directive('aGreatEye', function () {
return {
    restrict: 'E',
    replace: true,
    template: '<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>'
};
});

Эта директива используется как тег <a-great-eye></a-great-eye>. Она заменяет весь тег шаблоном <h1>lidless, wreathed in flame, {{1 + 1}} times</h1>. Теперь напишем jasmine-тест модуля, чтобы проверить эту функциональность. Обратите внимание, что выражение {{1 + 1}} раз(а) также будет вычислено в рендерном содержимом.

describe('Unit testing great quotes', function() {
var $compile;
var $rootScope;

// Load the myApp module, which contains the directive
beforeEach(module('myApp'));

// Store references to $rootScope and $compile
// so they are available to all tests in this describe block
beforeEach(inject(function(_$compile_, _$rootScope_){
  // The injector unwraps the underscores (_) from around the parameter names when matching
  $compile = _$compile_;
  $rootScope = _$rootScope_;
}));

it('Replaces the element with the appropriate content', function() {
    // Compile a piece of HTML containing the directive
    var element = $compile("<a-great-eye></a-great-eye>")($rootScope);
    // fire all the watches, so the scope expression {{1 + 1}} will be evaluated
    $rootScope.$digest();
    // Check that the compiled element contains the templated content
    expect(element.html()).toContain("lidless, wreathed in flame, 2 times");
});
});

Мы инжектируем сервис $compile и $rootScope перед каждым jasmine-тестом. Сервис $compile используется для рендеринга директивы aGreatEye. После рендеринга директивы мы убеждаемся, что директива заменила содержимое и «lidless, wreathed in flame, 2 раз(а)» присутствует.

Тестирование директив с трансклюзией

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

До компиляции:

<div translude-directive>
Some transcluded content
</div>

После извлечения трансклюзии:

<div transclude-directive></div>

После компиляции:

<div transclude-directive>
Some Template
<span ng-transclude>Some transcluded content</span>
</div>

Если директива использует трансклюзию 'element', компилятор фактически удалит весь элемент директивы из DOM и заменит его узлом комментария. Затем компилятор вставляет шаблон директивы «после» этого узла комментария как его соседнего элемента.

До компиляции

<div element-transclude>
Some Content
</div>

После извлечения трансклюзии

<!-- elementTransclude -->

После компиляции:

<!-- elementTransclude -->
<div element-transclude>
  Some Template
  <span ng-transclude>Some transcluded content</span>
</div>

Важно помнить об этом при написании тестов для директив, использующих трансклюзию 'element'. Если вы размещаете директиву на корневом элементе фрагмента DOM, который вы передаёте в $compile, то узлом DOM, возвращаемым из функции связывания, будет узел комментария, и вы потеряете возможность доступа к шаблону и трансклюдированному содержимому.

var node = $compile('<div element-transclude></div>')($rootScope);
expect(node[0].nodeType).toEqual(node.COMMENT_NODE);
expect(node[1]).toBeUndefined();

Для решения этой проблемы достаточно убедиться, что ваша директива трансклюзии 'element' заключена в элемент, такой как <div>.

var node = $compile('<div><div element-transclude></div></div>')($rootScope);
var contents = node.contents();
expect(contents[0].nodeType).toEqual(node.COMMENT_NODE);
expect(contents[1].nodeType).toEqual(node.ELEMENT_NODE);

Тестирование директив с внешними шаблонами

Если ваша директива использует templateUrl, рассмотрите использование karma-ng-html2js-preprocessor для предварительной компиляции HTML-шаблонов и, таким образом, избежать загрузки их по HTTP во время выполнения тестов. В противном случае могут возникнуть проблемы, если структура каталогов тестов отличается от структуры каталогов приложения.

Пример проекта

См. проект angular-seed для примера.

© 2010–2017 Google, Inc.
Licensed under the Creative Commons Attribution License 4.0.
https://code.angularjs.org/1.2.32/docs/guide/unit-testing

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API