Введение
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-repeatfirstWidget: первый виджет в списке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.fulfilledexpect(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 все еще молодой и растущий фреймворк. А какие методики используете вы?
This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.
Комментариев нет:
Отправить комментарий