[Перевод] AngularJS настоящее Модульное Тестирование
Введение 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 ('
self.parentScope.$digest ();
self.childElement = angular.element ('
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 все еще молодой и растущий фреймворк. А какие методики используете вы?