Personal Maps: тестирование AngularJS сервиса с помощью Jasmine и Karma. Часть 5.

Владимир | | Ajax, AngularJS, JavaScript, Web разработка.

personal_maps_logo_5

В прошлый раз мы создали сервис AngularJS под названием Places, через который происходит передача данных между клиентской и серверной частями приложения.

Наш сервис использует несколько встроенных компонентов Angular ($rootScope и $http) и не зависит от остальных компонентов приложения. С дугой стороны, остальные компоненты (контроллеры, директивы) используют методы сервиса. Любые изменения в названиях или количестве аргументов этих методов приведут к тому, что придется изменять все компоненты, которые их используют. Таким образом, полезно протестировать работу сервиса перед разработкой остальной части приложения. Этот подход можно использовать не только по отношению к сервисам – сначала разрабатываем и тестируем компоненты с наименьшим числом зависимостей, а затем собираем из них всё приложение.

Примечание. Исходный код размещён на GitHub, также доступна демоверсия приложения.

Source Demo

Прежде чем переходить к написанию тестов, давайте определим общие требования к тестированию.

Написание тестов занимает время. И полностью автоматизировать этот процесс нельзя, но его можно сократить за счёт использования библиотек и автоматизации запуска тестов. Также очень полезно иметь возможность запуска тестов из консоли, особенно если вы используете какой-нибудь 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 tests results

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.

На этом, мы завершаем работу с сервисом и в следующий раз рассмотрим контроллер и представления.

Если есть вопросы или замечания, пишите.
Успехов!

Содержание