Настоящее модульное тестирование в AngularJS

http://quickleft.com/blog/angularjs-unit-testing-for-real-though
  • Перевод
AngularJS молод и горяч, когда дело доходит до современной веб разработки. Его уникальный подход к компиляции HTML и двусторонней привязки данных делает его эффективным инструментом для создания клиентских веб приложений. Когда я узнал что Quick Left (студия в которой работает автор. прим. пер.) будет использовать его для создания приложения для одного из наших клиентов, я был взволнован и постарался узнать о angular столько сколько мог. Я обошел весь интернет, каждый урок и руководство, которые смог найти в Google. Они были реально полезны в понимании работы директив, шаблонов, компиляции и цикла обработки событий (digest), но когда дело дошло до тестирования, я обнаружил что эта тема была просто упущена.

Я обучался подходу TDD (Разработка через тестирование) и я чувствую себя не в своей тарелке без подхода «Красный-Зеленый-Рефакторинг». Так как мы все еще разбирались что к чему в тестировании в Angular, команде иногда приходилось полагаться на подход «тестирование-после». Это начало нервировать меня, поэтому я решил сосредоточится на тестировании. Я потратил на это недели, и в скором времени покрытие тестами поднялось с 40% до 86% (Кстати, если вы еще этого не делали, можете попробовать Istabul для проверки покрытия кода в вашем JS приложении).


Введение


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

  • Повторное использование страниц в End-to-End (e2e) тестах
  • Работа с функциями возвращающими Promise
  • Мокинг зависимостей контроллера и директив
  • Доступ к дочерним и изолированным scope


Эта статья предназначена для средних и продвинутых разработчиков, использующих AngularJS для написания боевых приложений, которая поможет уменьшить боль от тестирования. Я надеюсь чувство безопасности в рабочем процессе тестирования позволит читателю начать практиковать TDD подход и разрабатывать более устойчивые приложения.

Инструменты для тестирования


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

  • Karma: Запускатор тестов от команды AngularJS. Используйте его для запуска Chrome, Firefox, и PhantomJS.
  • AngularMocks: Дает поддержку для инъекции и мока Angular сервисов в модульном тестировании.
  • Protractor: Инструмент функционального тестирования для AngularJS, который запускает ваше приложение в браузере и взаимодействует с ним через Selenium.
  • Mocha: Написанный на node.js фреймворк для тестирования. Дает возможность писать describe блоки и делать проверки в них.
  • Chai: Assertion библиотека которая интегрируется в Mocha, и дает доступ к подходу BDD и возможность писать утверждения expect, should, и assert. В примерах мы будем использовать expect.
  • Chai-as-promised: Плагин для Chai, реально полезный при работе с функциями возвращающими promise. Он дает нам возможность писать так: expect(foo).to.be.fulfilled, или expect(foo).to.eventually.equal(bar).
  • Sinon: Стаб(Stub) и Мок(Mock) библиотека. Используйте ее для создания заглушек зависимостей в ваших директивах и контроллерах, и проверяйте что был вызов функций с корректными аргументами.
  • Browserify: Позволяет легко подключать модули между файлами в проекте.
  • Partialify: Позволяет подключать HTML шаблоны прямо в AngularJS директивы.
  • Lodash: Библиотека с плюшками и сахарком расширяющая стандартный функционал JavaScript.


Настройка Хелперов для Теста


Начнем с написания хелпера, который подключит нужные нам зависимости. Здесь мы будем использовать Angular Mocks, Chai, Chai-as-promised и Sinon

// test/test-helper.js

// подключаем наш проект
require('widgetProject');

// зависимости
require('angular-mocks');
var chai = require('chai');
chai.use('sinon-chai');
chai.use('chai-as-promised');

var sinon = require('sinon');

beforeEach(function() {
  // создаем новую песочницу перед каждым тестом
  this.sinon = sinon.sandbox.create();
});

afterEach(function() {
  // чистим песочницу, чтобы удалить все стабы
  this.sinon.restore();
});

module.exports = {
  rootUrl: 'http://localhost:9000',
  expect: chai.expect
}

Приступая к работе: Тестирование Сверху-Вниз


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

Для примера, я буду создавать воображаемое приложение «Widgets», которое может отображать список виджетов, создавать новые, и редактировать текущие. Кода, который вы здесь увидите, не достаточно для построения полноценного приложения, но достаточно чтобы понять примеры тестов. Мы начнем с написания e2e теста описывающего поведение создания нового виджета.

Повторное использование Страниц в e2e тестировании


Когда работаешь над одностраничным приложением, имеет смысл соблюдать принцип DRY через написание многократно используемых «страниц» которые можно подключать во множество e2e тестов.

Есть много способов структурировать тесты в Angular проекте. Сегодня, мы будем использовать такую структуру:

widgets-project
|-test
|  |
|  |-e2e
|  |  |-pages
|  |
|  |-unit

Внутри папки pages, мы создадим WidgetsPage функцию, которая может быть подключена в e2e тесты. На нее ссылаются пять тестов:

  • widgetRepeater: список виджетов содержащийся в ng-repeat
  • firstWidget: первый виджет в списке
  • widgetCreateForm: форма для создания виджета
  • widgetCreateNameField: поле для ввода имени виджета
  • widgetCreateSubmit: кнопка отправки формы

В конце получится что то типа этого:

// test/e2e/pages/widgets-page.js

var helpers = require('../../test-helper');

function WidgetsPage() {
  this.get = function() {
    browser.get(helpers.rootUrl + '/widgets');
  }

  this.widgetRepeater = by.repeater('widget in widgets');
  this.firstWidget = element(this.widgetRepeater.row(0));

  this.widgetCreateForm = element(by.css('.widget-create-form'));
  this.widgetCreateNameField = this.widgetCreateForm.element(by.model('widget.name');
  this.widgetCreateSubmit = this.widgetCreateForm.element(by.buttonText('Create');
}

module.exports = WidgetsPage

Изнутри моих e2e тестов, я теперь могу подключить эту страницу и взаимодействовать с её элементами. Вот как это можно использовать:
// e2e/widgets_test.js

var helpers = require('../test-helper');
var expect = helpers.expect;
var WidgetsPage = require('./pages/widgets-page');

describe('creating widgets', function() {
  beforeEach(function() {
    this.page = new WidgetsPage();
    this.page.get();
  });

  it('should create a new widget', function() {
    expect(this.page.firstWidget).to.be.undefined;
    expect(this.page.widgetCreateForm.isDisplayed()).to.eventually.be.true;
    this.page.widgetCreateNameField.sendKeys('New Widget');
    this.page.widgetCreateSubmit.click();
    expect(this.page.firstWidget.getText()).to.eventually.equal('Name: New Widget');
  });
});

Давайте посмотрим что здесь происходит. Сначала, мы подключаем тест хелпер, потом берем expect и WidgetsPage из него. В beforeEach мы загружаемся в страницу браузера. Затем, в примере, мы используем элементы которые определили в WidgetsPage для взаимодействия со страницей. Мы проверяем что нет виджетов, заполняем форму для создания одного из них значением «New Widget» и проверяем что он отображается на странице.

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

Работа с функциями возвращающими Promise


Assert методы, которые мы взяли из Protractor'a в тесте выше, возвращают Promise, поэтому мы используем Chai-as-promised для проверки, что функции isDisplayed и getText возвращают то что мы ожидаем.

Мы так же можем работать с promise объектами внутри модульных тестов. Давайте посмотрим на пример, в котором мы тестируем модальное окно, которое может быть использовано для редактирования существующего виджета. Оно использует сервис $modal из UI Bootstrap. Когда пользователь открывает модальное окно, сервис возвращает promise. Когда он отменяет или сохраняет окно, promise разрешается или отклоняется.
Давайте мы протестируем что save и cancel методы правильно подключены, задействовав Chai-as-promised.

// widget-editor-service.js
var angular = require('angular');
var _ = require('lodash');

angular.module('widgetProject.widgetEditor').service('widgetEditor', ['$modal', '$q', '$templateCache', function (
  $modal,
  $q,
  $templateCache
) {
  return function(widgetObject) {
    var deferred = $q.defer();

    var templateId = _.uniqueId('widgetEditorTemplate');
    $templateCache.put(templateId, require('./widget-editor-template.html'));

    var dialog = $modal({
      template: templateId
    });

    dialog.$scope.widget = widgetObject;

    dialog.$scope.save = function() {
      // Здесь сохраняем что-нибудь
      deferred.resolve();
      dialog.destroy();
    });

    dialog.$scope.cancel = function() {
      deferred.reject();
      dialog.destroy();
    });

    return deferred.promise;
  };
}]);

Сервис подгрузит шаблон редактирования виджета в кеш шаблонов, сам виджет, и создаст deferred объект, который будет разрешен или отклонен в зависимости от того, отклонит или сохранит пользователь форму редактирования, который вернет promise.

Вот как можно протестировать что-то на подобие этого:

// test/unit/widget-editor-directive_test.js

var angular = require('angular');
var helpers = require('../test_helper');
var expect = helpers.expect;

describe('widget storage service', function() {
  beforeEach(function() {
    var self = this;

    self.modal = function() {
      return {
        $scope: {},
        destroy: self.sinon.stub()
      }
    }

    angular.mock.module('widgetProject.widgetEditor', { $modal: self.modal });
  });

  it('should persist changes when the user saves', function(done) {
    var self = this;

    angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) {
      var widget = { name: 'Widget' };
      var promise = widgetModal(widget);

      self.modal.$scope.save();

      // каким то образом протестировали сохранение виджета
      expect(self.modal.destroy).to.have.been.called;
      expect(promise).to.be.fulfilled.and.notify(done);
st
      $rootScope.$digest();
    }]);
  });

  it('should not save when the user cancels', function(done) {
    var self = this;

    angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) {
      var widget = { name: 'Widget' };
      var promise = widgetModal(widget);

      self.modal.$scope.cancel();
      expect(self.modal.destroy).to.have.been.called;
      expect(promise).to.be.rejected.and.notify(done);

      $rootScope.$digest();
    }]);
  });
});

Чтобы справится со сложностью promise, который возвращает модальное окно в тесте редактирования виджета, мы можем сделать несколько вещей. Создать мок из сервиса $modal в функции beforeEach, заменив вывод функции на пустой объект $scope, и застабить вызов destroy. В angular.mock.module, мы передаем копию модального окна, чтобы Angular Mocks смог использовать его вместо реального $modal сервиса. Этот подход является довольно полезным для стаба зависимостей, в чем мы вскоре убедимся.

У нас есть два примера, и каждый должен ждать результата promise, возвращаемого виджетом редактирования, прежде чем завершится. В связи с этим мы должны передавать done как параметр в пример самостоятельно, и done когда тест завершится.

В тестах мы опять используем Angular Mocks для инъекции в модальное окно виджета и сервис $rootScope от AngularJS. Имея $rootScope мы можем вызывать цикл $digest. В каждом из тестов, мы загружаем модальное окно, отменяем или разрешаем его, и используем Chai-as-expected для проверки, вернулся promise как rejected или как resolved. Для фактического вызова promise и destroy, у нас должен запуститься $digest , поэтому он вызывается в конце каждого assert блока.

Мы рассмотрели как работать с promise в обоих случаях, в e2e и модульных тестах, используя следующие assert вызовы:

  • expect(foo).to.eventually.equal(bar)
  • expect(foo).to.be.fulfilled
  • expect(foo).to.be.rejected


Мок зависимостей Директив и Контроллеров


В прошлом примере у нас был сервис который полагался на $modal сервис, который мы замокали дабы убедится что destroy был действительно вызван. Прием который мы использовали, довольно полезен и позволяет модульным тестам работать более правильно в Angular.

Прием заключается в следующем:

  • Присвоить var self = this в блоке beforeEach.
  • Создать копию и застабать методы, затем сделать их свойствами self объекта:

    self.dependency = {
      dependencyMethod: self.sinon.stub()
    }
    
  • Передать копии в тестируемый модуль:
    angular.mock.module('mymodule', {
      dependency: self.dependecy,
      otherDependency: self.otherDependency
    });
    
  • Проверить замоканые методы в тестовых примерах. Вы можете использовать expect(foo).to.have.been.called.withArgs, передав аргументы которые вы ожидаете, для более лучшего покрытия.

Иногда директивы или контроллеры зависят от многих внутренних и внешних зависимостей, и вам нужно замокать их все.
Давайте взглянем на более сложный пример, в котором директива следит за widgetStorage сервисом и обновляет виджеты в своем окружении, при изменении коллекции. Так же есть метод edit который открывает widgetEditor созданный нами ранее.
// widget-viewer-directive.js

var angular = require('angular');

angular.module('widgetProject.widgetViewer').directive('widgetViewer', ['widgetStorage', 'widgetEditor', function(
  widgetStorage,
  widgetEditor
) {
  return {
    restrict: 'E',
    template: require('./widget-viewer-template.html'),
    link: function($scope, $element, $attributes) {
      $scope.$watch(function() {
        return widgetStorage.notify;
      }, function(widgets) {
        $scope.widgets = widgets;
      });

      $scope.edit = function(widget) {
        widgetEditor(widget);
      });
    }
  };
}]);

Вот как мы могли бы протестировать, что то подобное, замокав зависимости widgetStorage и widgetEditor:

// test/unit/widget-viewer-directive_test.js

var angular = require('angular');
var helpers = require('../test_helper');
var expect = helpers.expect;

describe('widget viewer directive', function() {
  beforeEach(function() {
    var self = this;

    self.widgetStorage = {
      notify: self.sinon.stub()
    };

    self.widgetEditor = self.sinon.stub();

    angular.mock.module('widgetProject.widgetViewer', {
      widgetStorage: self.widgetStorage,
      widgetEditor: self.widgetEditor
    });
  });

  // Остальная часть теста...
});

Доступ к Дочернему и Изолированному Scope


Иногда вам нужно написать директиву, которая имеет изолированный или дочерний scope внутри. Например, когда используется сервис $dropdown из Angular Strap, создается изолированный scope. Получить доступ к такому scope может оказаться довольно болезненным занятием. Но зная о self.element.isolateScope() можно исправить это. Вот один из примеров использования $dropdown, который создает изолированный scope:

// nested-widget-directive.js
var angular = require('angular');

angular.module('widgetSidebar.nestedWidget').directive('nestedSidebar', ['$dropdown', 'widgetStorage', 'widgetEditor', function(
  $dropdown,
  widgetStorage,
  widgetEditor
) {
  return {
    restrict: 'E',
    template: require('./widget-sidebar-template.html'),
    scope: {
      widget: '='
    },
    link: function($scope, $element, $attributes) {
      $scope.actions = [{
        text: 'Edit',
        click: 'edit()'
      }, {
        text: 'Delete',
        click: 'delete()'
      }]

      $scope.edit = function() {
        widgetEditor($scope.widget);
      });

      $scope.delete = function() {
        widgetStorage.destroy($scope.widget);
      });
    }
  };
}]);

Предполагая что директива наследует виджет от родительской директивы, которая имеет коллекцию виджетов, получить доступ к дочернему scope может быть довольно сложно, чтобы проверить изменились ли его свойства как положено. Но это можно сделать. Давайте глянем как:

// test/unit/nested-widget-directive_test.js
var angular = require('angular');
var helpers = require('../test_helper');
var expect = helpers.expect;

describe('nested widget directive', function() {
  beforeEach(function() {
    var self = this;

    self.widgetStorage = {
      destroy: self.sinon.stub()
    };

    self.widgetEditor = self.sinon.stub();

    angular.mock.module('widgetProject.widgetViewer', {
      widgetStorage: self.widgetStorage,
      widgetEditor: self.widgetEditor
    });

    angular.mock.inject(['$rootScope', '$compile', '$controller', function($rootScope, $compile, $controller) {
      self.parentScope = $rootScope.new();
      self.childScope = $rootScope.new();

      self.compile = function() {
        self.childScope.widget = { id: 1, name: 'widget1' };
        self.parentElement = $compile('<widget-organizer></widget-organizer>')(self.parentScope);

        self.parentScope.$digest();

        self.childElement = angular.element('<nested-widget widget="widget"></nested-widget>');

        self.parentElement.append(self.childElement);

        self.element = $compile(self.childElement)(self.childScope);
        self.childScope.$digest();
      }]);
    });

    self.compile();
    self.isolateScope = self.element.isolateScope();
  });

  it('edits the widget', function() {
    var self = this;
    self.isolateScope.edit();
    self.rootScope.$digest();
    expect(self.widgetEditor).to.have.been.calledWith(self.childScope.widget);
  });


Безумие, не правда ли? Сперва мы опять мокаем widgetStorage и widgetEditor, затем мы приступаем к написанию функции compile. Эта функция создаст два экземпляра scope, parentScope и childScope, застабим виджет и положим его в дочерний scope. Далее compile сделает настройку scope и сложный шаблон: сначала, скомпилирует родительский элемент widget-organizer, в которого будет передан родительский scope. Когда это все завершится, мы добавим дочерний элемент nested-widget к нему, передав дочерний scope и в конце запустим $digest.

В завершении, мы дойдем до магии: мы можем вызвать compile функцию, затем залезть в скомпилированный изолированный scope шаблона (который является scope от $dropdown) через self.element.isolateScope(). В конце теста, мы можем залезть в изолированный scope для вызова edit, и наконец проверить, что застабленый widgetEditor был вызван с застабленым виджетом.

Заключение


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

После того как мы выделили время понять как бороться со сложными случаями, стало намного легче в понимании, когда такие случаи снова встречаются. Вооружившись приемами описанными в этой статье, мы смогли влиться в процесс TDD и уверенно двинулись вперед.

Я надеюсь, что методики которые мы с вами посмотрели сегодня, окажутся полезными в вашей повседневной практике. AngularJS все еще молодой и растущий фреймворк. А какие методики используете вы?
  • +34
  • 38,3k
  • 1
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 1
  • 0
    Для тех кто использует `mocha` и `this` в `beforeEach` лучше чистить контекст после каждого теста,
    это позволить быть тестам более предсказуемыми:

    var clearContext = function(context){
      for (var prop in context) {
        if(context.hasOwnProperty(prop){
          delete context[prop];
        }
      }
    };
    
    ...
    
    afterEach(function(){
      this.sinon.restore();
      clearContext(this);
    });
    

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