...

вторник, 19 августа 2014 г.

[Перевод] 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('<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.


Комментариев нет:

Отправить комментарий