Unit-тестирование AngularJS-приложений

Как легко догадаться из названия, Unit-тестирование — это тестирование отдельных единиц (англ. unit) кода. Под единицей подразумевается небольшая изолированная часть кода, выполняющая конкретную функцию.

В сегодняшней статье мы посмотрим о том, как тестировать при помощи Jasmine различные сущности фрэймворка AngularJS. Каждая из них имеет свою специфику, поэтому нужно знать определённые шаблоны, правила и рекомендации, чтобы ни одна функция нашего приложения не осталась непокрытой!

В основном, статья рассчитана на разработчиков хотя бы немного знакомых с AngularJS и Jasmine, но не знающих, как тестировать создаваемое приложение, хотя начинающие знакомиться с этими фрэймворками тоже могут почитать, будет интересно!

Сразу скажу, чего в этой статье не будет:

  • мы не будем в рамках этой статьи настраивать окружение для тестирования;
  • не будем использовать загрузчики зависимостей (например, RequireJS);
  • не будем собирать проект — только исходники и тесты.

Mock-ирование внешних зависимостей

Для того, чтобы проверить какой-то участок кода изолированно, мы должны mock-ировать все внешние зависимости.

Многие спрашивают, зачем изолировать тестируемый код? Одна из главных причин — при ошибке в какой-то функции зависимости «завалятся» все тесты, которые эту функцию вызывают, и сразу понять, где произошла ошибка — не всегда получается. Эта проблема особенно заметна на большом количестве тестов, когда вместо пары-тройки неуспешных тестов мы можем получить ошибки чуть ли не во всех. Если же наш тестируемый код будет изолирован и эта функция зависимости будет заменена на некую заглушку, при ошибке в тестах мы сразу же видим, где и куда копать.

AngularJS написан с расчётом на максимальное удобство тестирования его частей. Этому также способствует и встроенных механизм внедрения зависимостей (Dependency Injection, DI), что помогает нам развязать части приложения друг от друга и, как следствие, тестировать их изолированно.

Сервисы (в широком смысле: service, factory, value, constant и provider) — наиболее распространённый вид зависимостей в AngularJS, который определяется с помощью provider-ов.

Angular-овский DI-injector получает экземпляры нужных объектов через соответствующие provider-ы.

Отсюда мы видим, как минимум, два пути mock-ирования наших сервисов: через сервис $provide или через переиспользуемый провайдер сервиса.

Mock-ирование через сервис $provide

Мы можем в Jasmine-овском beforeEach-блоке определить нашу собственную тестовую реализацию сервиса, создав объект со свойствами аналогичными оригинальному сервису. Например:

var externalService;
beforeEach(module(function($provide) {
  externalService = {
    methodNumberOne: function() {},
    methodNumberTwo: function() {}
  };
  $provide.value('externalService', externalService);
}));

Mock-ирование через переиспользуемый провайдер сервиса

Также мы можем создать файл external-service.mock.js, указав там тестовую реализацию сервиса, используя синтаксис провайдеров:

angular.module('externalServiceModuleMock', [])
  .provider('externalService', function() {
    this.$get = function() {
      return {
        methodNumberOne: function() {},
        methodNumberTwo: function() {}
      };
    };
  });

После этого мы должны запустить mock-модуль в beforeEach-блоке и достать с помощью injector‘а объект в контекст набора спецификаций (describe):

beforeEach(module('externalServiceModuleMock'));
var externalService;
beforeEach(inject(function(_externalService_) {
  externalService = _externalService_;
}));

Важно помнить, что запускать mock-модуль нужно после того, как запустили тестируемый модуль. Т.е. сначала мы определяем модуль с настоящей реализацией внешнего сервиса, после чего запускаем модуль с mock‘ом и перезаписываем уже определённый внешний сервис. Если сделать наоборот, мы перезапишем настоящей реализацией сервиса его mock и нужного нам эффекта не получим.

Шаблон тестирования

Код и спецификации тесно друг с другом связаны. Из этого факта вытекает полезная практика — располагать файл с тестами рядом с файлом компонента приложения, который мы тестируем, с указанием в имени того факта, что это спецификации. Например:

app/my-service.js
app/my-service.spec.js

Такой подход имеет свои плюсы и минусы. Применять ли его — решать вам. Естественно, в сборку, которая пойдёт на production, файлы спецификации идти не должны.

Можно упростить процесс тестирования, если придерживаться следующих правил при написании тестов:

  1. Опишите в блоке describe объект с указанием типа и названия,
  2. Запустите проверяемый модуль,
  3. Запустите модули с mock-ами компонент-зависимостей, если это нужно,
  4. Внедрите зависимости и настройте на нужных методах шпионов (spy),
  5. Инициализируйте объект:
    1. Сервисы просто внедряются,
    2. Контроллеры создаются через $controller-сервис,
    3. Для директив понадобится сервис $compile
  6. Группируйте спецификации в блоки describe.

Если следовать этим советам, у всех наших файлов спецификаций будет похожая структура и содержимое блоков beforeEach, запускающихся перед каждым тестом текущего набора. А во вложенных describe-блоках (внутри блоков it) будут содержаться самоописывающие ожидания. Пример:

describe('Service: carService', function() {
  beforeEach(module('carServiceModule'));

  var carService;
  beforeEach(inject(function(_carService_) {
    carService = _carService_;
  }));

  describe('Method: block', function() {
    it('should block the car', function() {
      expect(true).toBe(true);
    });
  });
});

Теперь перейдём к конкретному примеру. Разработаем мини-приложение с авторизацией пользователя, которое будет включать в себя все возможные сущности: контроллер, сервис, директиву и провайдер. Конечно, пример синтетический и задачи некоторых компонент выдуманы, но для нас сегодня главное — узнать, как тестировать, а что тестировать вы найдёте сами.

Контроллеры

Контроллеры достаточно просто тестировать, ведь они не манипулируют DOM-деревом, и у каждой функции есть единственная ясная цель.

В рамках тестирования контроллера мы должны проверить следующие вещи: состояние, синхронные и асинхронные вызовы сервисов и события.

Код контроллера

Мы будем сегодня писать сначала код, а затем тесты, проверяющие его. В общем, ни грамма TDD не будет. Но так вам будет намного проще осваивать тестирование, т.к. в начале очень важно видеть, что ты тестируешь.

Наш основной контроллер будет использовать controller as синтаксис. Также при объявлении контроллера мы внедряем $scope и внешние сервисы.

angular.module('login-app').controller('LoginController', ['$scope', '$rootScope', '$timeout', 'userService', 'inviteUser', function ($scope, $rootScope, $timeout, userService, inviteUser) {
    var vm = this;
    vm.login = '';
    vm.password = '';
    vm.user = null;
    vm.authError = null;
    vm.invitation = inviteUser.getInvitation();

    $scope.$on('user.auth.success', function (event, userData) {
        vm.user = userData;
    });

    vm.onLogin = function () {
        userService.login(vm.login, vm.password).then(function (userData) {
            vm.login = '';
            vm.password = '';
            $scope.$emit('user.auth.success', userData);
        }, function (error) {
            vm.authError = 'Error #' + error.code + ': ' + error.message;
            $timeout(function () {
                vm.authError = null;
            }, 5000);
        });
    };
}]);

Внутри контроллера мы объявляем нужные для представления методы и свойства, в обработчике вызываем сервис асинхронно с установкой обработчиков успеха и ошибки. И ещё — используем $scope для обработки и отправки событий, а $timeout для очищения сообщения об ошибке.

Спецификации контроллера

А теперь давайте подумаем, как нам всё это протестировать. Выше мы уже ознакомились с рекомендациями по написанию тестов. Напишем основную структуру для файла со спецификациями. Не забываем про запуск модуля с mock-ами после запуска тестируемого модуля контроллера:

describe('Controller: LoginController', function () {
    beforeEach(module('login-app'));
    beforeEach(module('user-service-mock'));
    beforeEach(module('invite-user-provider-mock'));
});

Готово! Теперь для тестирования вызовов других внешних сервисов нам нужно установить шпиона (spy) на их методы. В Jasmine это делается через функцию spyOn, которая позволит отслеживать вызовы и получаемые при этом аргументы.

С методом шпиона callThrough() мы можем делегировать вызов заглушки настоящей реализации метода сервиса. Но для асинхронных методов нам нужно mock-ировать поведение, возвращая promise, который можно получить из сервиса $q. Давайте получим экземпляры всех наших зависимостей и настроим их.

var userService,
    inviteUser,
    loginDefer;

// Mock services and spy on methods
beforeEach(inject(['$q', 'userService', 'inviteUser', function ($q, _userService_, _inviteUser_) {
    $timeout = _$timeout_;
    inviteUser = _inviteUser_;
    inviteUser.getInvitation.and.returnValue('MyInvitation!');

    loginDefer = $q.defer();
    userService = _userService_;
    userService.login.and.returnValue(loginDefer.promise);
}]));

Для чистоты и большей ясности кода наших тестов мы инициализируем контроллер в другом beforeEach-блоке.

var ctrl;
var $scope;
beforeEach(inject(['$controller', '$rootScope', function ($controller, $rootScope) {
    $scope = $rootScope.$new();
    ctrl = $controller('LoginController', {$scope: $scope});
}]));

Начнём тестирование состояния нашего контроллера. Т.к. мы используем controller as синтаксис, нам не нужно тестировать состояние в $scope, мы будем тестировать открытые наружу свойства и методы контроллера напрямую.

describe('State', function () {
    it('should have empty properties after initialization', function () {
        expect(ctrl.login).toBe('');
        expect(ctrl.password).toBe('');
        expect(ctrl.user).toBe(null);
        expect(ctrl.authError).toBe(null);
    });

    it('should have correct invitation', function () {
        expect(inviteUser.getInvitation).toHaveBeenCalled();
	expect(ctrl.invitation).toBe('MyInvitation!');
    });
});
describe('Method: login', function () {
    it('should exist', function () {
        expect(angular.isFunction(ctrl.onLogin)).toBe(true);
    });
...

Мы проверили, что все свойства и методы, которые мы хотим видеть у контроллера снаружи, присутствуют в том виде, который мы ожидаем.

Теперь приступим к тестированию обработчика попытки пользователя авторизоваться. Для начала проверим, что наш обработчик вызывает сервис по работе с пользователем. Как я уже говорил выше, для тестирования вызовов сервисов используем spy из Jasmine.

it('should call user service', function () {
    ctrl.onLogin();

    expect(userService.login).toHaveBeenCalled();
    expect(userService.login.calls.count()).toBe(1);
});

А теперь убедимся, что мы отдаём сервису нужные параметры в правильном порядке. Для тестирования реального поведения метода, мы должны проверить его с разными аргументами.

it('should call user service with correct params', function () {
    ctrl.login = 'myLogin';
    ctrl.password = 'myPassword';
    ctrl.onLogin();

    expect(userService.login).toHaveBeenCalledWith(ctrl.login, ctrl.password);
});

it('should call user service with correct params 2', function () {
    ctrl.login = 'myLogin2';
    ctrl.password = 'myPassword2';
    ctrl.onLogin();

    expect(userService.login).toHaveBeenCalledWith(ctrl.login, ctrl.password);
});

Вдобавок проверки вызовов методов сервисов, мы должны проверить вызовы разрешающих обещания (promise) функций при асинхронных событиях.

В процессе настройки набора тестов мы указали асинхронным вызовам возвращать обещание, которое теперь мы можем разрешить или отклонить, эмулируя успешное завершение асинхронного события или его ошибку. Обратите внимание на вызов $scope.$apply(); после разрешения или отклонения обещания.

it('should clear form after positive promise resolve', function () {
    ctrl.login = 'myLogin';
    ctrl.password = 'myPassword';
    ctrl.onLogin();
    loginDefer.resolve();
    $scope.$apply();

    expect(ctrl.login).toBe('');
    expect(ctrl.password).toBe('');
});

it('should correctly handle promise reject', function () {
    ctrl.onLogin();
    loginDefer.reject({code: 123, message: 'My error'});
    $scope.$apply();

    expect(ctrl.authError).toEqual('Error #123: My error');
});

it('should clear auth error property after 5s timeout', function () {
    ctrl.onLogin();
    loginDefer.reject({code: 123, message: 'My error'});
    $scope.$apply();

    expect(ctrl.authError).toBeTruthy();

    $timeout.flush();
    expect(ctrl.authError).toBe(null);
    $timeout.verifyNoPendingTasks();
});

А с синтаксисом controller as единственное место, где нам нужно проверить $scope — это создание и прослушивание событий.

it('should emit correct event after positive promise resolve', function () {
    var handlerSpy = jasmine.createSpy('eventHandler');
    $scope.$on('user.auth.success', handlerSpy);
    ctrl.onLogin();
    loginDefer.resolve({user: 'success'});
    $scope.$apply();

    expect(handlerSpy).toHaveBeenCalled();
    expect(handlerSpy.calls.argsFor(0)[1]).toEqual({user: 'success'});
});

it('should set user data after auth event', function () {
    $scope.$emit('user.auth.success', {user: 'auth'});

    expect(ctrl.user).toEqual({user: 'auth'});
});

Вот и всё! Так мы полностью покрыли наш контроллер тестами. Можем двигаться дальше!

Сервисы

AngularJS-сервисы тестировать ещё проще, чем контроллеры! Здесь нам надо будет помимо проверки вызовов других сервисов, проверять ещё и возвращаемый результат методов и обработку HTTP-запросов. Но поскольку мы не хотим во время выполнения unit-тестов слать XMLHTTPRequest на реальный HTTP-сервер, будем использовать сервис $httpBackend из модуля ngMock. Я в своем блоге уже писал про специфику работы с $httpBackend при тестировании и заглушки HTTP-сервера в AngularJS-приложениях..

Код сервиса

Наш сервис будет уметь делать всего одно действие — асинхронно выполнять HTTP-запрос с использованием встроенного сервиса $http для авторизации пользователя.

angular.module('login-app').factory('userService', ['$http', '$q', function ($http, $q) {
    function login(login, password) {
        return $http.post('users/login', {
            login: login,
            password: password
        }).then(function (response) {
            return response.data;
        }, function (response) {
            var data = response.data || {};
            return $q.reject({
                code: data.serverErrorCode || -1,
                message: data.serverErrorMessage || 'Undefined error'
            })
        });
    }

    return {
        login: login
    }
}]);

Заметим, что если мы вернём из обработчика ошибки значение, не соответствующее обещанию, обещание асинхронного вызова разрешится положительно, а не будет отклонено. Поэтому мы должны явно отклонить его.

Спецификации сервиса

Настраивая тест, мы должны получить сервис $httpBackend для mock-ирования обращений к HTTP API и проверить ожидаемые результаты.

describe('Service: userService', function () {
    var userService,
    	$httpBackend;

    beforeEach(module('login-app'));
    beforeEach(inject(['userService', '$httpBackend', function (_userService_, _$httpBackend_) {
        userService = _userService_;
        $httpBackend = _$httpBackend_;
    }]));

    afterEach(function () {
        $httpBackend.verifyNoOutstandingExpectation();
        $httpBackend.verifyNoOutstandingRequest();
    });
});

Обратите внимание на необходимость блока afterEach с вызовами указанных методов. Зачем это нужно можно почитать, опять-таки. тут

Веселье начинается, когда мы приступаем к написанию тестов на асинхронные функции.

В наших тестах для быстрых прогонов и простоты развёртывания окружения мы используем mock-реализацию $httpBackend для эмуляции ответов сервера без реальных HTTP-запросов. Таким образом, нам не придётся для прогона всех тестов разворачивать web-сервер с соответствующим нашему приложению HTTP API.

Поскольку запросы обрабатываются асинхронно, нам нужно эмулировать их завершение методом flush. По этой причине мы добавляем блок afterEach, который будет верифицировать, что после выполнения каждого теста не осталось ни одного ожидающего запроса или ожидания сервера.

Для тестирования того, что наш backend вызывается, нам всего лишь нужно создать ожидания с адресом ресурса в API.

it('should send POST request', function () {
    $httpBackend.expectPOST(/.*/ig).respond(200);
    userService.login();
    $httpBackend.flush();
});

it('should send request to correct URL', function () {
    $httpBackend.expectPOST('users/login').respond(200);
    userService.login();
    $httpBackend.flush();
});

it('should send request with correct params', function () {
    $httpBackend.expectPOST('users/login', {login: 'my_login', password: 'my_password'}).respond(200);
    userService.login('my_login', 'my_password');
    $httpBackend.flush();
});

it('should return promise', function () {
    $httpBackend.expectPOST('users/login').respond(200);
    var result = userService.login();
    $httpBackend.flush();

    expect(result.then).toBeDefined();
});

Тут мы проверили, что все наши HTTP-запросы создаются с верным типом, адресом и параметрами. Кто-то спросит, зачем разделять эти тесты, если можно сделать один. Вопрос вполне справедливый, но при такой декомпозиции тестов легче определить причину ошибки (неверный тип, адрес или параметры тела запроса)

Теперь приступим к тестированию возвращаемых значений.

Тут нам понадобится создать ожидание запроса, которое будет разрешаться с успешным статусом и данными о пользователе или же со статусом ошибки и с объектом ошибки.

А ещё снаружи вызовов метода сервиса мы повесим шпиона на обработчики события обещания, чтобы определить, что в результате возвращаемое значение соответствует нашим ожиданиям.

it('should return user object after success response', function () {
    var userObject = {
        name: 'myName',
        lastName: 'myLastName'
    };
    var spyHandler = jasmine.createSpy('successHandler');
    $httpBackend.expectPOST('users/login').respond(200, userObject);
    userService.login().then(spyHandler);
    $httpBackend.flush();

    expect(spyHandler).toHaveBeenCalledWith(userObject);
});

it('should correctly handle server error', function () {
    $httpBackend.expectPOST('users/login').respond(500, {
        serverErrorCode: 123,
        serverErrorMessage: 'Error text'
    });
    var spyHandler = jasmine.createSpy('errorHandler');
    userService.login().then(null, spyHandler);
    $httpBackend.flush();

    expect(spyHandler).toHaveBeenCalledWith({code: 123, message: 'Error text'});
});

it('should correctly handle server error without description', function () {
    $httpBackend.expectPOST('users/login').respond(500);
    var spyHandler = jasmine.createSpy('errorHandler');
    userService.login().then(null, spyHandler);
    $httpBackend.flush();

    expect(spyHandler).toHaveBeenCalledWith({code: -1, message: 'Undefined error'});
});

В случае тестирования синхронных методов сервиса всё гораздо проще: нам нужно только сравнить результат их выполнения с тем, что мы ожидаем. Никаких ожиданий запроса, описаний бэкенда, обещаний и их разрешений.

Директивы

Тестировать директивы немного по-сложнее всего изученного нами выше. Тут нам придётся использовать сервис $compile, компилировать их вручную и тестировать полученное скомпилированное DOM-дерево.

Код директивы

Создадим придуманную нами директиву с изолированным scope’ом, которая будет загружать свой шаблон с формой авторизации и вызывать переданную функцию с нужными параметрами. Для использования шаблона в unit-тестах нам придётся при запуске Karma с помощью препроцессора из пакета ng-html2js сконвертировать их в AngularJS-модуль с заданным в конфигурации Karma именем, содержащий наши шаблоны в строковом виде. Обратите внимание на файл karma.conf.js в репозитории нашего приложения, там все эти настройки можно увидеть воочию. Сам шаблон вы можете найти там же.

angular.module('login-app').directive('loginForm', [function () {
    return {
        restrict: 'E',
        templateUrl: 'templates/login-form.htm',
        replace: true,
        scope: {
            login: '=',
            password: '=',
            onLogin: '&',
            error: '='
        },
        link: function (scope, el) {
            var submitBtn = angular.element(el[0].querySelector('[type="submit"]'));
            var submitInitText = submitBtn.text();

            scope.$watch('error', function (newValue) {
                if (newValue) {
                    submitBtn.text(newValue)
                        .addClass('error-btn');
                } else {
                    submitBtn.text(submitInitText)
                        .removeClass('error-btn');
                }
            });
        }
    }
}]);

Спецификации директивы

Для тестирования всего этого нам нужно загрузить модуль директивы после загрузки модуля, содержащего все шаблоны, который мы сконфигурировали до этого с помощью препроцессора ng-html2js.

Дальше мы компилируем директиву через $compile и отправляем в качестве scope директивы нами заранее подготовленный scope. После этого нам нужно вызвать $digest-цикл.

describe('Directive: loginForm', function () {
    var scope,
        el;

    beforeEach(module('login-app'));
    beforeEach(module('app-templates'));
    beforeEach(inject(['$rootScope', '$compile', function ($rootScope, $compile) {
        scope = $rootScope.$new();
        scope.login = '';
        scope.password = '';
        scope.onLogin = jasmine.createSpy('onLogin');
        scope.error = '';

        var loginForm = angular.element('');
        el = $compile(loginForm)(scope);
        scope.$digest();
        el = $(el);
    }]));
});

Обратите внимание, что в onLogin мы создаём шпиона, с помощью которого будем определять, вызывался ли этот обработчик при клике по кнопке отправки формы.

Используя методы jQuery или jqLite, мы можем проверить, что получилось после рендеринга нашего шаблона. Я в тестах предпочитаю использовать jQuery, т.к. часто функционала jqLite не хватает.

Проверим основные элементы и их значения.

it('should correctly render form', function () {
    scope.login = 'my-login';
    scope.password = 'my-password';
    scope.$digest();

    expect(el.prop('tagName')).toBe('FORM');
    expect(el.find('#login').val()).toBe('my-login');
    expect(el.find('#password').val()).toBe('my-password');
    expect(el.find('#submit').prop('tagName')).toBe('BUTTON');
});

А когда scope изменяется, нам нужно проверить, изменилось ли новое значение в представлении.

it('should correctly render error text on submit button after text change', function () {
    expect(el.find('#submit').text()).toBe('Log in');

    scope.error = 'My error';
    scope.$digest();
    expect(el.find('#submit').text()).toBe('My error');
    expect(el.find('#submit').hasClass('error-btn')).toBe(true);

    scope.error = '';
    scope.$digest();
    expect(el.find('#submit').text()).toBe('Log in');
    expect(el.find('#submit').hasClass('error-btn')).toBe(false);
});

it('should call onLogin method on submit click', function () {
    el.find('#submit').click();
    expect(scope.onLogin).toHaveBeenCalled();
});

Обязательно обратите внимание на вызовы $digest-цикла при каждом изменении значения scope-а. Иначе изменения не будут отражены в представлении, т.к. осуществлялись извне контекста выбранного нами фрэймворка.

В нашем примере у директивы нет контроллера, но при необходимости его можно протестировать, достав его через вызов element.controller() с названием директивы в качестве параметра. Вот пример:

describe('Directive controller', function() {
    var controller;
    beforeEach(function() {
        controller = element.controller('myDirective');
    });

    it('should do something', function() {
        expect(controller.doSomething).toBeDefined();
        controller.doSomething();
        expect(controller.something.name).toBe('Do something');
    });
});

Провайдеры

Тестирование провайдеров сложнее всего, потому что мы должны перехватывать их до того, как они внедряются. Но как только мы выполнили этот шаг, мы можем тестировать их как любой другой сервис.

Код провайдера

Наш провайдер inviteUser-провайдер может быть сконфигурирован для приглашения пользователя с разными обращениями.

angular.module('login-app')
    .provider('inviteUser', function () {
        var name;
        return {
            configure: function (value) {
                name = value;
            },
            $get: function () {
                return {
                    getInvitation: function () {
                        name = name || 'guest';
                        return 'Hello my Dear ' + name + '! Log in, please.';
                    }
                };
            }
        };
    });

Спецификации провайдера

Мы перехватываем провайдера, когда загружаем модуль перед внедрением зависимостей.

describe('Provider: inviteUser', function () {
    var inviteUserProvider,
        inviteUser;

    beforeEach(module('login-app', function (_inviteUserProvider_) {
        inviteUserProvider = _inviteUserProvider_;
    }));

    beforeEach(inject(function (_inviteUser_) {
        inviteUser = _inviteUser_;
    }));
});

И после этого мы можем тестировать оба получившихся экземпляра, как если бы делали это с любым другим сервисом. Приступим:

it('should correctly invite with default', function () {
    inviteUserProvider.configure(null);
    expect(inviteUser.getInvitation()).toBe('Hello my Dear guest! Log in, please.');
});

it('should correctly invite after configure', function () {
    inviteUserProvider.configure('Comrade');
    expect(inviteUser.getInvitation()).toBe('Hello my Dear Comrade! Log in, please.');
});

it('should correctly configure invitation after app start', function () {
    expect(inviteUser.getInvitation()).toMatch(/^Hello my Dear [a-z]+! Log in, please\.$/i);
});

Заключение

Вот и всё! Статья получилась внушительной по размеру, но я надеюсь, что она поможет многим разработчикам наконец-то начать покрывать тестами свой код.

Я понимаю, что, дочитав и взглянув на всё вышенаписанное, у читателя может сложиться впечатление, что unit-тестирование — это страшно и очень сложно. Но после того, как вы освоите основные приёмы, тестирование web-приложения станет приятным процессом, а вы будете увереннее при написании или изменении кода. Ведь признайтесь, каждый раз выкатывая новый код у вас есть волнение за то, что вы что-то сломали. Ручные тестировщики тоже люди, они не могут после каждой изменённой строки кода прогонять все тесты вручную. А если приложение ещё и растёт, то становится жаль этих ребят, они обречены :) Поэтому выход один — писать авто-тесты!

И ещё важный момент — обратите внимание, что каждый тест получился компактным, маленьким, понятным, проверяющими что-то одно. Не пытайтесь делать большие сложные тесты с множеством проверок. И, тем более, не добавляйте в тесты логику с условиями. Всё это усложнит чтение тестов и их отладку. Если у нескольких тестов есть одинаковая инициализация, их можно объединить в один набор через describe и вынести общую инициализацию в beforeEach-метод.

И надо заметить, unit-тестирование — это всего лишь верхушка айсберга, потому что позволяет нам проверить только изолированные куски кода. Чтобы проверить, хорошо ли работают все эти кусочки вместе после интеграции, нам нужно проводить «end to end»-тестирование (e2e) и для этого мы можем использовать специализированный для таких целей фрэймворк Protractor, созданный командой разработчиков AngularJS. В статье в обучающих целях показана полная изоляция тестируемых компонентов друг от друга. Уровень изоляции при тестировании вы можете выбирать по своему усмотрению и проверять несколько компонентов сразу. Тут важно найти свою золотую середину для оптимизации стоимости написания и поддержки тестов и пользы, которую тестирование вам приносит.

Весь проект можно найти на GitHub’е. Также можете посмотреть DEMO нашего мини-приложения. Сразу предупреждаю, что при попытке авторизации всегда будет показываться ошибка, т.к. запросы идут в никуда.

Я искренне надеюсь, что у вас получится в этом разобраться и мой маленький вклад в ваши успехи тоже есть! На этом всё! До новых статей!

  • Дмитрий Василевский

    Не совсем понял, как у вас работает такой код без вызова spy:
    `inviteUser.getInvitation.and.returnValue(‘MyInvitation!’);`

    У меня работает только так
    `spyOn(inviteUser, ‘getInvitation’).and.returnValue($q.defer().promise);`

    • Дмитрий, Спасибо! Сказать по правде, не понимаю, почему вам в spy нужно возвращать promise.
      inviteUser.getInvitation у нас ведь в статье синхронный и никак с promise’ами не связан.
      Может, вы как-то переиначили код своего приложения? В таком случае, конечно, тесты будут отличаться.
      Вот здесь код приложения со всеми работающими тестами, посмотрите :)

      • Дмитрий Василевский

        На промайс не обращайте внимания, я о вот этой части:
        inviteUser.getInvitation.and.returnValue
        Мне интерпретатор ругался, что returnValue не определен.
        UserService.all.and.returnValue(mockUsers);

        TypeError: Cannot read property ‘returnValue’ of undefined

        • А, понял в чём дело) у меня это делается в этом подключаемом моке inviteUser-провайдера. А старт этого модуля моков происходит вот так: beforeEach(module(‘invite-user-provider-mock’));
          В таком простом случае вынесение в отдельный файл кажется избыточным, но при разрастании приложения с таким подходом удобно без лишних движений поменять в одном месте мок, который применится везде, где он подключается.
          Как альтернатива, настраивать моки в самом beforeEach или непосредственно в тесте. Всё зависит от случая и предпочтений)

          • Дмитрий Василевский

            Интересный подход. Теперь понятно, спасибо :)