В прошлый раз мы создали сервис AngularJS под названием Places
, через который происходит передача данных между клиентской и серверной частями приложения.
Наш сервис использует несколько встроенных компонентов Angular ($rootScope
и $http
) и не зависит от остальных компонентов приложения. С дугой стороны, остальные компоненты (контроллеры, директивы) используют методы сервиса. Любые изменения в названиях или количестве аргументов этих методов приведут к тому, что придется изменять все компоненты, которые их используют. Таким образом, полезно протестировать работу сервиса перед разработкой остальной части приложения. Этот подход можно использовать не только по отношению к сервисам – сначала разрабатываем и тестируем компоненты с наименьшим числом зависимостей, а затем собираем из них всё приложение.
Примечание. Исходный код размещён на GitHub, также доступна демоверсия приложения.
Прежде чем переходить к написанию тестов, давайте определим общие требования к тестированию.
Написание тестов занимает время. И полностью автоматизировать этот процесс нельзя, но его можно сократить за счёт использования библиотек и автоматизации запуска тестов. Также очень полезно иметь возможность запуска тестов из консоли, особенно если вы используете какой-нибудь CI (Continuous Integration) сервер.
Для решения всех перечисленных задач мы используем:
- Фреймворк Jasmine. В принципе, его можно заменить на какой-нибудь другой, но в примерах в документации AngularJS используется именно он. Кроме того, Jasmine в любом случае является одним из самых популярных.
- Утилиту для запуска тестов Karma test runner. Она позволяет запускать тесты из консоли, следит за изменениями файлов и перезапускает тесты при их изменениях.
- PhantomJS – так называемый headless браузер, т.е. без графического интерфейса. Работает на движке webkit. Пока вы разрабатываете локально, вам не принципиально, какой браузер использовать, но на linux-сервере без иксов обычные браузеры просто не запустятся.
Установка и настройка окружения
В Yii фреймворке тесты находятся в папке protected/tests
. Нас это размещение вполне устраивает.
Т.е. структура папок будет такой:
protected/ tests/ libs/ //дополнительные JS библиотеки unit/ js/ controllers/ services/ directives/ karma.conf.js //файл конфигурации Karma
Установка Karma test runner
Для работы Karma необходим Node.JS. Скачайте инсталлятор для вашей операционной системы и просто следуйте инструкциям. Если вы устанавливаете Node из исходников, то вам потребуется добавить в переменную PATH
путь к исполняемым файлам.
Karma устанавливается с помощью команды:
npm install -g karma
npm скачает все необходимые файлы, и вы сможете выполнять из консоли команды вроде
karma start
После этого, необходимо создать переменные окружения, в которых будет указано размещение браузеров, которые должна использовать Karma. Например, для Google Chrome и PhantomJS нужно создать следующие переменные.
CHROME_BIN="полный_путь\chrome.exe" PHANTOMJS_BIN="полный_путь\phantomjs.exe"
Настраиваем Karma test runner
Технически Karma запускает свой web сервер, который по-умолчанию использует порт 9876. Это даёт возможность разместить файлы тестов вне DOCUMENT_ROOT
сервера, который используется для приложения.
Создадим конфигурационный файл karma.conf.js
с помощью следующей команды
karma init karma.conf.js
Эту команду необходимо выполнить из папки protected/tests
. В результате будет создан файл с настройками по-умолчанию. Нам нужно их немного изменить для того, чтобы утилита знала, где находятся файлы проекта и файлы тестов.
// base path, that will be used to resolve files and exclude basePath = ''; // list of files / patterns to load in the browser files = [ JASMINE, JASMINE_ADAPTER, 'http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js', 'http://maps.googleapis.com/maps/api/js?sensor=false', '../../js/markdown.js', 'http://ajax.googleapis.com/ajax/libs/angularjs/1.0.7/angular.min.js', 'libs/angular-mocks.js', '../../js/ui-bootstrap-tpls-0.4.0.min.js', '../../js/angular-translate.min.js', 'unit/js/bootstrap.js', '../../js/app.js', '../../js/controllers/*.js', '../../js/services/*.js', '../../js/directives/*.js', 'unit/js/controllers/*.spec.js', 'unit/js/services/*.spec.js', 'unit/js/directives/*.spec.js' ]; // list of files to exclude exclude = []; // test results reporter to use // possible values: 'dots', 'progress', 'junit' reporters = ['progress']; // web server port port = 9876; // cli runner port runnerPort = 9100; // enable / disable colors in the output (reporters and logs) colors = true; // level of logging // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG logLevel = LOG_DEBUG; // enable / disable watching file and executing tests whenever any file changes autoWatch = true; // Start these browsers, currently available: // - Chrome // - ChromeCanary // - Firefox // - Opera // - Safari (only Mac) // - PhantomJS // - IE (only Windows) browsers = ['Chrome']; // If browser does not capture in given timeout [ms], kill it captureTimeout = 60000; // Continuous Integration mode // if true, it capture browsers, run tests and exit singleRun = false;
Прежде всего, указываем размещение файлов проекта в массиве files
(строки 5-23).
JASMINE
и JASMINE_ADAPTER
подключают фреймворк Jasmine вместе с адаптером для Karma (необходимые файлы входят в дистрибутив Karma).
Затем мы подключаем все сторонние библиотеки, которые используются приложением. Их порядок должен соответствовать последовательности их подключения на странице приложения.
Кроме того, нам понадобиться библиотека angular-mocks.js
(строка 12, найти эту библиотеку можно здесь), которая содержит mock-объекты для встроенных сервисов AngularJS. С их помощью мы, например, сможем тестировать работу сервиса $http
, не отправляя реальных запросов на сервер (об этом чуть ниже).
Также подключаем скрипт tests/unit/js/bootstrap.js
. В нём мы создадим JS объекты, которые при работе приложения устанавливаются с помощью PHP кода. Например, язык приложения указывается в конфигурационном файле Yii main.php
. При формировании страницы приложения, значение этого параметра присваивается JS переменной lang
. Это позволяет указать языковые настройки только один раз, а не отдельно для серверной и клиентской части. Но в результате, переменная lang
оказывается недоступной для тестов, т.к. Karma формирует страницу самостоятельно, без использования нашего PHP кода.
Наконец, мы подключаем JavaScript файлы приложения и тесты (строки 17-22). Обратите внимание, мы можем использовать *
для того, чтобы подключить все файлы в папке.
Из остальных параметров мы установим для уровня логгирования значение LOG_DEBUG
(строка 43) и укажем, какой браузер Karma должна использовать (строка 56). Вообще, LOG_DEBUG
использовать не обязательно. Просто в этом режиме в консоль выводятся сообщения о подключении JS файлов, и если вы допустите ошибку при формировании массива files
, то так будет проще её найти.
Параметр singleRun = false
(строка 63) означает, что после запуска Karma будет отслеживать изменения и автоматически перезапускать тесты.
Запуск Karma выполняется с помощью команды
karma start
или
karma start karma.conf.js
После её выполнения запустится браузер, а в консоли вы увидите результаты выполнения тестов.
Karma test runner мы настроили. Теперь напишем тесты.
Тестирование сервиса Places
Создадим файл tests/unit/js/services/places.spec.js
describe('Places service', function() { ... });
Функция describe
описывает набор тестов. В первом параметре указывается название набора, а во втором – функция, содержащая тесты.
Тест состоит из набора спецификаций (создаются с помощью функции
it
), которые содержат «ожидания» (вызовы функции expect
). Например, простой тест может выглядеть так:
describe('Places service', function() { it('returns particular place', function() { expect(true).toEqual(true); }); });
Но в нашем случае ситуация сложнее. Нам необходимо тестировать код, который отправляет AJAX запросы. Если тест будет отправлять реальные запросы, то возникнет ряд проблем:
- Время выполнения теста увеличится, т.к. он будет ждать ответы сервера.
- Результаты сервера могут зависеть от состояния базы данных. Можно, конечно, создать специальную базу с тестовыми данными, но процесс тестирования в любом случае станет сложнее.
- Серверная часть может оказаться в нерабочем состоянии, например, потому что она просто не полностью написана и сама содержит ошибки.
Поэтому нам важно запускать тесты автономно. И в этом нам поможет библиотека angular-mocks.js
. Она содержит mock-объект $httpBackend
, который эмулирует работу сервиса $http
. Рассмотрим, как его подключить и использовать.
describe('Places service', function() { var $httpBackend, injector; var response = [ { 'id': 1, 'p_title': 'title 1', 'p_description': 'desc 1', "p_lng": 50.4, "p_lat": 30.76, 'p_user': 1 }, ... ]; var newPlace = { 'id': '3', 'p_title': 'title 3', 'p_description': 'desc 3', 'p_lng': '50.4', 'p_lat': '30.76', 'p_user': 1 }; beforeEach(function() { module('personalmaps', function($provide) { $provide.value('lang', ''); }); inject(function($injector, lang) { injector = $injector; $httpBackend = $injector.get('$httpBackend'); $httpBackend.when('GET', 'api/places').respond(response); $httpBackend.when('POST', 'api/places').respond(newPlace); $httpBackend.when('PUT', 'api/places/2').respond(response[1]); $httpBackend.when('PUT', 'api/places/5').respond(response[1]); $httpBackend.when('DELETE', 'api/places/2').respond(''); $httpBackend.when('GET', 'foo/bar.json?lang=en').respond('[]'); }); }); afterEach(function() { $httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingRequest(); }); it('calls api/places', function() { $httpBackend.expectGET('api/places'); injector.get('Places'); $httpBackend.flush(); }); ... });
Перед запуском тестов мы создаём массивы с тестовыми данными, которые будет возвращать $httpBackend
(строки 4-23). Формат этих данных должен совпадать с форматом ответа реального сервера.
Затем с помощью функции beforeEach
настроим $httpBackend
. Эта функция вызывается перед каждым вызовом it
.
Тут есть несколько важных моментов. При создании приложения AngularJS выполняет довольно много работы, которую в случае использования тестов мы должны выполнить самостоятельно. Перед каждым тестом мы создаём модуль (строки 26-28) и вызываем функцию inject
, которая определена в файле angular-mocks.js
. Она создаёт объект $injector
, с помощью которого можно использовать механизм внедрения зависимостей (Dependency injection).
При создании компонентов AngularJS мы указываем списки зависимостей. Например, при создании нашего сервиса мы так подключили $http
и $rootScope
. Но наш тест ничего не знает о том, что нам нужен $httpBackend
и сам сервис Places
. $injector
как раз и позволяет подключить их, т.е. «внедрить» в наш тест.
Посмотрите, в строке 32 мы использовали метод $injector.get
для того, чтобы получить $httpBackend
, а в строке 49 мы с помощью injector
подключили сервис Places
.
После того, как $httpBackend
подключён, его нужно настроить. Т.е. указать какие запросы он будет обрабатывать, и какие ответы отправлять.
Например,
$httpBackend.when('GET', 'api/places').respond(response);
означает, что если где-нибудь в приложении будет выполнен вызов
$http({method: 'GET', url: 'api/places'})
то $httpBackend
его перехватит и вернёт значение response
.
Таким образом, мы определяем ответы на все ajax запросы, которые отправляет наш сервис. Реальные запросы при этом не отправляются, т.е. работоспособность серверной части приложения нас не интересует, и мы всегда будем уверены, что получили правильный ответ.
Теперь взгляните на код теста (строки 47-51). Он проверяет, что сразу после создания приложения сервис Places
отправляет запрос к api/places
, который должен вернуть полный список объектов.
Мы вызываем
$httpBackend.expectGET('api/places');
т.е. указываем, что ожидаем отправки AJAX-запроса. Затем подключаем сервис
injector.get('Places');
в результате AngularJS выполнит функцию сервиса Places
(файл public_html/js/services/places.js
), которая отправит запрос. Т.к. мы хотим получить результат сразу, то вызываем
$httpBackend.flush();
Напомню, AJAX запросы являются асинхронными, поэтому ответ может прийти в любой момент времени. При вызове flush
все отправленные запросы будут завершены и вернут ответ. Таким образом, ответ на запрос к api/places
будет получен во время выполнения теста и «ожидание» (expectGET
) будет успешным.
После завершения теста Jasmine автоматически вызовет функцию afterEach
(строки 42-45). В ней мы с помощью методов verifyNoOutstandingExpectation
и verifyNoOutstandingRequest
закрываем все «ожидания» и запросы. Если мы этого не сделаем, то выполнение одного из тестов может повлиять на работу остальных, а этого допускать нельзя.
Как видите, тестирование асинхронного JavaScript кода довольно не тривиальная задача, даже с использованием фреймворков. Но, по-сути, вам нужно научиться использовать несколько дополнительных объектов, и хотя бы в общих чертах разобраться в принципе подключения объектов в Angular (это желательно сделать не только ради тестов). После этого вы увидите, что все тесты используют очень похожий код. Например, рассмотрим следующий тест.
it('returns null for unknown places', function() { var places = injector.get('Places'); $httpBackend.flush(); expect(places.get('43534535')).toBe(null); });
Он проверяет, что метод Places.get
возвращает null
если объект с заданным id
не найден. Мы подключаем сервис Places
. Сервис выполняет запрос и в ответ получает массив response
. Посмотрите, в этом массиве нет объекта с id
равным 43534535
. Затем вызываем $httpBackend.flush()
, т.е. обеспечиваем получение ответа до того, как будет выполнена следующая строка. И с помощью метода places.get
ищем объект с id == 43534535
. Т.к. такого объекта нет, то мы ожидаем, что метод вернёт null
.
Остальные тесты работают аналогично, вы можете посмотреть их на GitHub.
На этом, мы завершаем работу с сервисом и в следующий раз рассмотрим контроллер и представления.
Если есть вопросы или замечания, пишите.
Успехов!
Содержание
- Personal Maps: используем Yii и AngularJS для разработки web приложения. Часть 1.
- Personal Maps: Устанавливаем и настраиваем Yii, проектируем структуру базы данных. Часть 2.
- Personal Maps: главная страница и структура клиентской части приложения. Часть 3.
- Personal Maps: создаём сервис AngularJS. Часть 4.
- Personal Maps: тестирование AngularJS сервиса с помощью Jasmine и Karma. Часть 5.
- Personal Maps: контроллеры и представления в AngularJS. Часть 6.
- Personal maps: создаём директиву для подключения Google Maps. Часть 7.
- Personal maps: REST интерфейс. Часть 8.
- Personal Maps: авторизация и аутентификация (с использованием Yii RBAC). Часть 9.
- Personal Maps: локализация и интернационализация. Часть 10