AngularJS молод и горяч, когда дело доходит до современной веб разработки. Его уникальный подход к компиляции HTML и двусторонней привязки данных делает его эффективным инструментом для создания клиентских веб приложений. Когда я узнал что Quick Left (студия в которой работает автор. прим. пер.) будет использовать его для создания приложения для одного из наших клиентов, я был взволнован и постарался узнать о angular столько сколько мог. Я обошел весь интернет, каждый урок и руководство, которые смог найти в Google. Они были реально полезны в понимании работы директив, шаблонов, компиляции и цикла обработки событий (digest), но когда дело дошло до тестирования, я обнаружил что эта тема была просто упущена.
Я обучался подходу TDD (Разработка через тестирование) и я чувствую себя не в своей тарелке без подхода «Красный-Зеленый-Рефакторинг». Так как мы все еще разбирались что к чему в тестировании в Angular, команде иногда приходилось полагаться на подход «тестирование-после». Это начало нервировать меня, поэтому я решил сосредоточится на тестировании. Я потратил на это недели, и в скором времени покрытие тестами поднялось с 40% до 86% (Кстати, если вы еще этого не делали, можете попробовать Istabul для проверки покрытия кода в вашем JS приложении).
Сегодня я хочу поделится некоторыми вещами, которым я научился. Таким же хорошим как и документация по Angular, тестирование боевого приложения редко бывает таким же простым как в примерах, которые вы увидите ниже. Есть много подводных камней, через которые мне пришлось пройти, чтобы заставить некоторые вещи работать. Я нашел несколько обходных путей, которые мне пригождались вновь и вновь. В этой статье мы рассмотрим некоторые из них
Эта статья предназначена для средних и продвинутых разработчиков, использующих AngularJS для написания боевых приложений, которая поможет уменьшить боль от тестирования. Я надеюсь чувство безопасности в рабочем процессе тестирования позволит читателю начать практиковать TDD подход и разрабатывать более устойчивые приложения.
Есть много фреймворков и инструментов для тестирования доступных Angular разработчику, и возможно у вас уже есть свои предпочтения. Вот список инструментов, которые мы выбрали и будем использовать по ходу статьи.
Начнем с написания хелпера, который подключит нужные нам зависимости. Здесь мы будем использовать Angular Mocks, Chai, Chai-as-promised и Sinon
Я большой сторонник стиля тестирования «сверху-вниз». Все начинается с функционала который я хочу создать, я пишу псевдосценарий описывающий функционал и создаю feature тест. Я запускаю этот тест и он валится с ошибкой. Теперь я могу начать проектировать все части системы, которые мне нужны чтобы feature тест заработал, используя модульные тесты, направляющие меня на этом пути.
Для примера, я буду создавать воображаемое приложение «Widgets», которое может отображать список виджетов, создавать новые, и редактировать текущие. Кода, который вы здесь увидите, не достаточно для построения полноценного приложения, но достаточно чтобы понять примеры тестов. Мы начнем с написания e2e теста описывающего поведение создания нового виджета.
Когда работаешь над одностраничным приложением, имеет смысл соблюдать принцип DRY через написание многократно используемых «страниц» которые можно подключать во множество e2e тестов.
Есть много способов структурировать тесты в Angular проекте. Сегодня, мы будем использовать такую структуру:
Внутри папки
В конце получится что то типа этого:
Изнутри моих e2e тестов, я теперь могу подключить эту страницу и взаимодействовать с её элементами. Вот как это можно использовать:
Давайте посмотрим что здесь происходит. Сначала, мы подключаем тест хелпер, потом берем
Теперь, разделив логику для формы в многоразовую «страницу», мы можем многократно ее использовать, для тестирования валидации формы, например, или позже в других директивах.
Assert методы, которые мы взяли из Protractor'a в тесте выше, возвращают Promise, поэтому мы используем Chai-as-promised для проверки, что функции
Мы так же можем работать с promise объектами внутри модульных тестов. Давайте посмотрим на пример, в котором мы тестируем модальное окно, которое может быть использовано для редактирования существующего виджета. Оно использует сервис
Давайте мы протестируем что
Сервис подгрузит шаблон редактирования виджета в кеш шаблонов, сам виджет, и создаст deferred объект, который будет разрешен или отклонен в зависимости от того, отклонит или сохранит пользователь форму редактирования, который вернет promise.
Вот как можно протестировать что-то на подобие этого:
Чтобы справится со сложностью promise, который возвращает модальное окно в тесте редактирования виджета, мы можем сделать несколько вещей. Создать мок из сервиса
У нас есть два примера, и каждый должен ждать результата promise, возвращаемого виджетом редактирования, прежде чем завершится. В связи с этим мы должны передавать
В тестах мы опять используем Angular Mocks для инъекции в модальное окно виджета и сервис
Мы рассмотрели как работать с promise в обоих случаях, в e2e и модульных тестах, используя следующие assert вызовы:
В прошлом примере у нас был сервис который полагался на $modal сервис, который мы замокали дабы убедится что
Прием заключается в следующем:
Иногда директивы или контроллеры зависят от многих внутренних и внешних зависимостей, и вам нужно замокать их все.
Давайте взглянем на более сложный пример, в котором директива следит за
Вот как мы могли бы протестировать, что то подобное, замокав зависимости
Иногда вам нужно написать директиву, которая имеет изолированный или дочерний scope внутри. Например, когда используется сервис
Предполагая что директива наследует виджет от родительской директивы, которая имеет коллекцию виджетов, получить доступ к дочернему scope может быть довольно сложно, чтобы проверить изменились ли его свойства как положено. Но это можно сделать. Давайте глянем как:
Безумие, не правда ли? Сперва мы опять мокаем
В завершении, мы дойдем до магии: мы можем вызвать
Тестирование может быть болезненным. Я помню несколько случаев, когда в нашем проекте было столько боли в выяснении как все это делать, что был соблазн вернутся к написанию кода и «клик тестированию», для проверки работоспособности. К сожалению, когда вы выходите из этого процесса, чувство неуверенности только увеличивается.
После того как мы выделили время понять как бороться со сложными случаями, стало намного легче в понимании, когда такие случаи снова встречаются. Вооружившись приемами описанными в этой статье, мы смогли влиться в процесс TDD и уверенно двинулись вперед.
Я надеюсь, что методики которые мы с вами посмотрели сегодня, окажутся полезными в вашей повседневной практике. AngularJS все еще молодой и растущий фреймворк. А какие методики используете вы?
Я обучался подходу 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 все еще молодой и растущий фреймворк. А какие методики используете вы?