Spec-Zone.ru › Angular.js 1.8

Улучшить эту документациюТестирование отдельных модулей

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

Разделение задач

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

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

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

В AngularJS мы стремимся сделать правильный подход простым. Для запросов XHR мы предоставляем инъекцию зависимостей, позволяющую имитировать запросы. Для DOM мы реализуем абстракцию, позволяющую тестировать модель без непосредственного манипулирования DOM. Тесты могут проверять, что данные отсортированы, не создавая или не просматривая состояние DOM и не дожидаясь возврата данных запросами XHR. Функцию сортировки можно тестировать изолированно.

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

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

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

AngularJS имеет встроенную инъекцию зависимостей, что значительно упрощает тестирование компонентов, поскольку позволяет передавать зависимости компонента и подменять или имитировать их по своему усмотрению.

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

Дополнительные инструменты для тестирования приложений AngularJS

Для тестирования приложений AngularJS есть определённые инструменты, которые значительно упрощают настройку и выполнение тестирования.

Karma

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

Karma — приложение NodeJS, устанавливается через npm/yarn. Полные инструкции по установке доступны на сайте Karma.

Jasmine

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

В Jasmine используется функция describe для группирования тестов:

describe("sorting the list of users", function() {
  // individual tests go here
});

А каждый отдельный тест определяется внутри вызова функции it:

describe('sorting the list of users', function() {
  it('sorts in descending order by default', function() {
    // your test assertion goes here
  });
});

Группировка связанных тестов в блоках describe и описание каждого отдельного теста в вызове функции it обеспечивает самодокументированность тестов.

Наконец, Jasmine предоставляет средства проверки (matchers), которые позволяют создавать утверждения:

describe('sorting the list of users', function() {
  it('sorts in descending order by default', function() {
    var users = ['jack', 'igor', 'jeff'];
    var sorted = sortUsers(users);
    expect(sorted).toEqual(['jeff', 'jack', 'igor']);
  });
});

Jasmine поставляется с рядом средств проверки, которые помогают создавать различные утверждения. Следует прочитать документацию Jasmine, чтобы узнать о них. Для использования Jasmine с Karma используется тестовый запуск karma-jasmine.

angular-mocks

AngularJS также предоставляет модуль ngMock, который обеспечивает возможность имитации (мокинг) в тестах. Он используется для инъекции и имитации AngularJS-сервисов в модульных тестах. Кроме того, он может расширять другие модули, делая их синхронными. Синхронные тесты более чистые и простые в работе. Одна из наиболее полезных частей ngMock — $httpBackend, позволяющая имитировать запросы XHR в тестах и возвращать примерные данные.

Тестирование контроллера

Поскольку AngularJS разделяет логику и слой представления, контроллеры легко тестировать. Давайте посмотрим, как мы можем протестировать контроллер ниже, который предоставляет $scope.grade, устанавливая свойство в области видимости (scope) на основе длины пароля.

angular.module('app', [])
.controller('PasswordController', function PasswordController($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';
    }
  };
});

Поскольку контроллеры недоступны в глобальной области видимости, нам необходимо использовать angular.mock.inject для инъекции контроллера. В первую очередь используется функция module, предоставляемая angular-mocks. Она загружает заданный модуль, делая его доступным в ваших тестах. Мы передаём его в beforeEach, функцию, предоставляемую Jasmine, позволяющую выполнять код перед каждым тестом. Затем мы можем использовать inject для доступа к $controller, службе, отвечающей за создание контроллеров.

describe('PasswordController', function() {
  beforeEach(module('app'));

  var $controller, $rootScope;

  beforeEach(inject(function(_$controller_, _$rootScope_){
    // The injector unwraps the underscores (_) from around the parameter names when matching
    $controller = _$controller_;
    $rootScope = _$rootScope_;
  }));

  describe('$scope.grade', function() {
    it('sets the strength to "strong" if the password length is >8 chars', function() {
      var $scope = $rootScope.$new();
      var controller = $controller('PasswordController', { $scope: $scope });
      $scope.password = 'longerthaneightchars';
      $scope.grade();
      expect($scope.strength).toEqual('strong');
    });
  });
});

Обратите внимание, как вложенность вызовов describe и описательность при вызове с использованием строк делает тест очень понятным. Он точно документирует то, что тестируется, и с первого взгляда можно быстро понять, что происходит. Теперь добавим тест для случая, когда длина пароля меньше трёх символов, что должно установить $scope.strength в «слабый»:

describe('PasswordController', function() {
  beforeEach(module('app'));

  var $controller;

  beforeEach(inject(function(_$controller_){
    // The injector unwraps the underscores (_) from around the parameter names when matching
    $controller = _$controller_;
  }));

  describe('$scope.grade', function() {
    it('sets the strength to "strong" if the password length is >8 chars', function() {
      var $scope = {};
      var controller = $controller('PasswordController', { $scope: $scope });
      $scope.password = 'longerthaneightchars';
      $scope.grade();
      expect($scope.strength).toEqual('strong');
    });

    it('sets the strength to "weak" if the password length <3 chars', function() {
      var $scope = {};
      var controller = $controller('PasswordController', { $scope: $scope });
      $scope.password = 'a';
      $scope.grade();
      expect($scope.strength).toEqual('weak');
    });
  });
});

Теперь у нас есть два теста, но обратите внимание на дублирование между тестами. Оба должны создать переменную $scope и создать контроллер. По мере добавления новых тестов это дублирование будет только ухудшаться. К счастью, Jasmine предоставляет beforeEach, позволяющую выполнить функцию перед каждым отдельным тестом. Давайте посмотрим, как это упорядочит наши тесты:

describe('PasswordController', function() {
  beforeEach(module('app'));

  var $controller;

  beforeEach(inject(function(_$controller_){
    // The injector unwraps the underscores (_) from around the parameter names when matching
    $controller = _$controller_;
  }));

  describe('$scope.grade', function() {
    var $scope, controller;

    beforeEach(function() {
      $scope = {};
      controller = $controller('PasswordController', { $scope: $scope });
    });

    it('sets the strength to "strong" if the password length is >8 chars', function() {
      $scope.password = 'longerthaneightchars';
      $scope.grade();
      expect($scope.strength).toEqual('strong');
    });

    it('sets the strength to "weak" if the password length <3 chars', function() {
      $scope.password = 'a';
      $scope.grade();
      expect($scope.strength).toEqual('weak');
    });
  });
});

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

Тестирование фильтров

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

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

describe('length filter', function() {

  var $filter;

  beforeEach(inject(function(_$filter_){
    $filter = _$filter_;
  }));

  it('returns 0 when given null', function() {
    var length = $filter('length');
    expect(length(null)).toEqual(0);
  });

  it('returns the correct value when given a string of chars', function() {
    var length = $filter('length');
    expect(length('abc')).toEqual(3);
  });
});

Тестирование директив

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

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

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

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,
      $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 times» присутствует.

Нотация с нижним подчеркиванием: Использование нотации с нижним подчеркиванием (например: _$rootScope_) — распространённая в сообществе AngularJS конвенция для поддержания чистоты имён переменных в тестах. Именно поэтому $injector удаляет ведущие и заключительные нижние подчеркивания при сопоставлении параметров. Правило нижних подчеркиваний применяется только, если имя начинается и заканчивается ровно одним нижним подчеркиванием, в противном случае замена не выполняется.

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

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

Перед компиляцией:

<div transclude-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 во время выполнения тестов. В противном случае могут возникнуть проблемы, если структура каталогов тестов отличается от структуры каталогов приложения.

Тестирование промисов

При тестировании промисов важно знать, что разрешение промисов связано с циклом then. Это означает, что функции обратного вызова catch, finally и $apply промиса вызываются только после выполнения цикла переваривания. В тестах вы можете запустить цикл переваривания, вызвав функцию $apply области видимости. Если у вас нет области видимости в тесте, вы можете ввести $rootScope и вызвать $q на нем. В документации к сервису $q также есть пример тестирования промисов.

Использование beforeAll()

Фиксы beforeAll() Jasmine и before() mocha часто полезны для совместного настройки тестов — либо для уменьшения времени выполнения тестов, либо просто для создания более сфокусированных тестовых случаев.

По умолчанию ngMock создаёт инжектор для каждого тестового случая, чтобы ваши тесты не влияли друг на друга. Однако, если мы хотим использовать beforeAll(), ngMock должен создать инжектор до запуска каких-либо тестовых случаев и предоставить этот инжектор всем случаям для этого describe. Для этого пригодится module.sharedInjector(). Когда она вызывается в describe блоке, один инжектор используется для всех фиксов и тестовых случаев, выполняемых в этом блоке.

В примере ниже мы тестируем сервис, который долго генерирует свой ответ. Чтобы избежать написания всех утверждений в одном тестовом случае, используются module.sharedInjector() и beforeAll() Jasmine для запуска сервиса только один раз. Тестовые случаи затем делают утверждения о свойствах, добавленных в экземпляр сервиса.

describe("Deep Thought", function() {

  module.sharedInjector();

  beforeAll(module("UltimateQuestion"));

  beforeAll(inject(function(DeepThought) {
    expect(DeepThought.answer).toBeUndefined();
    DeepThought.generateAnswer();
  }));

  it("has calculated the answer correctly", inject(function(DeepThought) {
    // Because of sharedInjector, we have access to the instance of the DeepThought service
    // that was provided to the beforeAll() hook. Therefore we can test the generated answer
    expect(DeepThought.answer).toBe(42);
  }));

  it("has calculated the answer within the expected time", inject(function(DeepThought) {
    expect(DeepThought.runTimeMillennia).toBeLessThan(8000);
  }));

  it("has double checked the answer", inject(function(DeepThought) {
    expect(DeepThought.absolutelySureItIsTheRightAnswer).toBe(true);
  }));

});

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

Обратитесь к проекту angular-seed для примера.

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

Spec-Zone.ru

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