В прошлый раз мы создали сервис 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


