Unit Testing в AngularJS: сервисы, контроллеры и провайдеры

Unit Testing в AngularJS: сервисы, контроллеры и провайдеры

AngularJS специально разработан с перспективой возможности тестирования. Dependency Injection является одной из самый выдающихся особенностей фреймворка, которая делает Unit тестирование проще. AngularJS определяет способ, как аккуратно модуляризировать приложение и разделить его на несколько компонентов, таких как контроллеры, директив, фильтры или анимации. Эта модель развития означает, что отдельные части работают в изоляции, и приложение может легко масштабировать в течение длительного периода времени. Так как расширяемость и тестируемость идут рука об руку, проверить код AngularJS можно довольно легко.

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

Сервисы тестирования

Сервисы являются одними из наиболее распространенных компонентов в применении AngularJS. Они обеспечивают способ определения повторного использования логики в центральном месте, так что нет необходимости повторять ту же логику снова и снова. Суть сервисного синглтона позволяет делиться одной и той же частью данных по нескольким контроллерам, директивам и даже другим сервисам.

Сервис может зависеть от множества других услуг, чтобы выполнить свою задачу. Скажем, сервис А зависит от сервисов B, C и D, чтобы выполнить свою задачу. Во время тестирования сервиса А, зависимые B, C и D должны быть заменены моками.

Как правило, все зависимые сервисы заменяются фиктивными, за исключением некоторых утилити сервисов, таких как $rootScope и $prase. Мы создаем шпионов в методах, которые должны быть проверены тестами (в Jasmine на моки ссылаются как на шпионы), используя jasmine.createSpy(), которые создают новую функцию.

Давайте рассмотрим следующий сервис:

  1. angular.module('services', [])
  2.   .service('sampleSvc', ['$window', 'modalSvc', function($window, modalSvc){
  3.     this.showDialog = function(message, title){
  4.       if(title){
  5.         modalSvc.showModalDialog({
  6.           title: title,
  7.           message: message
  8.         });
  9.       } else {
  10.         $window.alert(message);
  11.       }
  12.     };
  13.   }]);

В этом сервисе есть только один метод (showDialog). В зависимости от величины входного, который получает этот метод, он вызывает один из двух сервисов, которые вводятся в него в качестве зависимостей ($window или modalSvc).

Чтобы протестировать sampleSvc мы должны заменить оба сервиса фиктивными, загрузить модуль angular, содержащий наш сервис, и получить ссылки на все объекты:

  1. var mockWindow, mockModalSvc, sampleSvcObj;
  2. beforeEach(function(){
  3.   module(function($provide){
  4.     $provide.service('$window', function()
  5.       this.alert= jasmine.createSpy('alert');
  6.     });
  7.     $provide.service('modalSvc', function(){
  8.       this.showModalDialog = jasmine.createSpy('showModalDialog');
  9.     });
  10.   });
  11.   module('services');
  12. });

  13. beforeEach(inject(function($window, modalSvc, sampleSvc){
  14.   mockWindow=$window;
  15.   mockModalSvc=modalSvc;
  16.   sampleSvcObj=sampleSvc;
  17. }));

Теперь мы можем протестировать поведение метода showDialog. Два тестовых примера которые можно написать для этого метода заключаются в следующем:

  • вызывается alert, если нет title параметр передается в него
  • вызывается showModalDialog если оба title и message параметры присутствуют

Следующий фрагмент показывает эти тесты:

  1. it('should show alert when title is not passed into showDialog', function(){
  2.   var message="Some message";
  3.   sampleSvcObj.showDialog(message);

  4.   expect(mockWindow.alert).toHaveBeenCalledWith(message);
  5.   expect(mockModalSvc.showModalDialog).not.toHaveBeenCalled();
  6. });

  7. it('should show modal when title is passed into showDialog', function(){
  8.   var message="Some message";
  9.   var title="Some title";
  10.   sampleSvcObj.showDialog(message, title);

  11.   expect(mockModalSvc.showModalDialog).toHaveBeenCalledWith({
  12.     message: message,
  13.     title: title
  14.   });
  15.   expect(mockWindow.alert).not.toHaveBeenCalled();
  16. });

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


Тестирования контроллеров

Процесс установки для тестирования контроллеров отличается от установки сервисов. Это потому, что контроллеры не являются инъекционными, они подтверждаются автоматически, когда route загружается или, когда директив ng-controller завершен. Поскольку мы не можем видеть загрузку во время тестирования, нужно вручную иллюстрировать примерами тестируемый контроллер.

Так как контроллеры зачастую привязаны к view, поведение методов контроллеров от него зависит. Кроме того, некоторые дополнительные объекты могут быть добавлены в scope после того, как view был скомпилирован. Один из самых распространенных примеров этого является form object. Для того, чтобы тестирование проходило так как предполагалось, эти объекты должны быть созданы вручную и добавлены к контроллеру.

Контроллер может быть одним из следующих типов:

  • Контроллер используемый с $scope 
  • Контроллер используемый с синтаксисом Controller as 

Тестирование контроллеров со $scope

Учтите следующий контроллер:

  1. angular.module('controllers',[])
  2.   .controller('FirstController', ['$scope','dataSvc', function($scope, dataSvc) {
  3.     $scope.saveData = function () {
  4.       dataSvc.save($scope.bookDetails).then(function (result) {
  5.         $scope.bookDetails = {};
  6.         $scope.bookForm.$setPristine();
  7.       });
  8.     };

  9.     $scope.numberPattern = /^\d*$/;
  10.   }]);

Чтобы протестировать этот контроллер, нам нужно создать образец контроллера, прохождением через обьект $scope и замокированный объект сервиса (dataSvc). Поскольку услуга содержит асинхронный метод, нам нужно замокать это с помощью mocking promise technique.

Следующий фрагмент мокает сервис dataSvc:

  1. module(function($provide){
  2.   $provide.factory('dataSvc', ['$q', function($q)
  3.     function save(data){
  4.       if(passPromise){
  5.         return $q.when();
  6.       } else {
  7.         return $q.reject();
  8.       }
  9.     }
  10.     return{
  11.       save: save
  12.     };
  13.   }]);
  14. });

Мы можем потом создать новый scope для контроллера, используя метод $rootScope.$new. После создания образца контроллера у нас есть все поля и контроллеры в этом новом $scope

  1. beforeEach(inject(function($rootScope, $controller, dataSvc){
  2.   scope=$rootScope.$new();
  3.   mockDataSvc=dataSvc;
  4.   spyOn(mockDataSvc,'save').andCallThrough();
  5.   firstController = $controller('FirstController', {
  6.     $scope: scope, 
  7.     dataSvc: mockDataSvc
  8.   });
  9. }));

После того, как контроллер добавляет поле и метод в $scope, мы можем проверить, установлены ли они на нужные значения и, имеют ли методы правильную логику. Образец контроллера выше, добавляет “обычное” выражение для проверки правильности числа. Давайте добавим спецификацию, чтобы проверить поведение “обычного” выражения:

  1. it('should have assigned right pattern to numberPattern', function(){
  2.     expect(scope.numberPattern).toBeDefined();
  3.     expect(scope.numberPattern.test("100")).toBe(true);
  4.     expect(scope.numberPattern.test("100aa")).toBe(false);
  5. });

Если контроллер инициализирует любые объекты со значениями по умолчанию, мы можем проверить их значения в спецификации.

Для проверки метода saveData, мы должны установить некоторые значения для объектов bookDetails и bookForm. Эти объекты будут связаны с элементами пользовательского интерфейса, так же, как и те, которые создаются во время выполнения, когда соблюдается view. Как уже упоминалось, мы должны вручную инициализировать их с некоторыми значениями перед вызовом метода saveData.

Следующий фрагмент проверяет этот метод:

  1. it('should call save method on dataSvc on calling saveData', function(){
  2.     scope.bookDetails = {
  3.       bookId: 1, 
  4.       name: "Mastering Web application development using AngularJS", 
  5.       author:"Peter and Pawel"
  6.     };
  7.     scope.bookForm = {
  8.       $setPristine: jasmine.createSpy('$setPristine')
  9.     };
  10.     passPromise = true;
  11.     scope.saveData();
  12.     scope.$digest();
  13.     expect(mockDataSvc.save).toHaveBeenCalled();
  14.     expect(scope.bookDetails).toEqual({});
  15.     expect(scope.bookForm.$setPristine).toHaveBeenCalled();
  16. });

Тестирование контроллеров с синтаксисом "Controller as"

Тестировать контроллер, который использует синтаксис Controller as, проще, чем тестировать его, используя $scope. В этом случае, образец контроллера играет роль модели. Следовательно, все действия и объекты доступны для этого образца.

Рассмотрим следующий контроллер:

  1. angular.module('controllers',[])
  2.   .controller('SecondController', function(dataSvc){
  3.     var vm=this;

  4.     vm.saveData = function () {
  5.       dataSvc.save(vm.bookDetails).then(function(result) {
  6.         vm.bookDetails = {};
  7.         vm.bookForm.$setPristine();
  8.       });
  9.     };

  10.     vm.numberPattern = /^\d*$/;
  11.   });

Процесс вызова этого контроллера аналогична процессу, рассмотренному выше. Разница лишь в том, что мы не должны создавать $scope.

  1. beforeEach(inject(function($controller){
  2.   secondController = $controller('SecondController', {
  3.     dataSvc: mockDataSvc
  4.   });
  5. }));

Когда все элементы и методы в контроллере добавлены к этому образцу, мы можем получить к ним доступ с помощью ссылки на образец.

Следующий фрагмент тестирует поле numberPattern, добавленное к контроллеру выше:

  1. it('should have set pattern to match numbers', function(){
  2.   expect(secondController.numberPattern).toBeDefined();
  3.   expect(secondController.numberPattern.test("100")).toBe(true);
  4.   expect(secondController.numberPattern.test("100aa")).toBe(false);
  5. });

Утверждения метода saveData остаются такими же. Единственное отличие этого подхода заключается в том, как мы инициализируем значения в объекты bookDetails и bookForm.

Следующий фрагмент показывает спецификацию:

  1. it('should call save method on dataSvc on calling saveData', function () 
  2.   secondController.bookDetails = {
  3.     bookId: 1,
  4.     name: "Mastering Web application development using AngularJS",
  5.     author: "Peter and Pawel"
  6.   };
  7.   secondController.bookForm = {
  8.     $setPristine: jasmine.createSpy('$setPristine')
  9.   };
  10.   passPromise = true;
  11.   secondController.saveData();
  12.   rootScope.$digest();
  13.   expect(mockDataSvc.save).toHaveBeenCalled();
  14.   expect(secondController.bookDetails).toEqual({});
  15.   expect(secondController.bookForm.$setPristine).toHaveBeenCalled();<
  16. });

Тестирование Провайдера

Провайдеры используются для того, чтобы выставить API для конфигурирования приложений в масштабах, которые должны быть сделаны до начала программы. Когда конфигурация приложения AngularJS закончена, взаимодействие с провайдерами запрещается. Следовательно, провайдеры доступны только в конфигурирующихся блоках, или других провайдерских блоках. Мы не можем получить образец провайдера, используя инъекционный блок, мы должны передать функцию обратного вызова к модульному блоку.

Давайте рассмотрим следующий провайдер, который зависит от постоянного (appConstants) второго провайдера (anotherProvider):

  1. angular.module('providers', [])
  2.   .provider('sample', function(appConstants, anotherProvider){

  3.     this.configureOptions = function(options){
  4.       if(options.allow){
  5.         anotherProvider.register(appConstants.ALLOW);
  6.       } else {
  7.         anotherProvider.register(appConstants.DENY);
  8.       }
  9.     };

  10.     this.$get = function(){};
  11.   });

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

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

Следующий фрагмент получает ссылки и загружает модули:

  1. beforeEach(module("providers"));
  2. beforeEach(function(){
  3.   module(function(anotherProvider, appConstants, sampleProvider){
  4.     anotherProviderObj=anotherProvider;
  5.     appConstantsObj=appConstants;
  6.     sampleProviderObj=sampleProvider;
  7.   });
  8. });
  9. beforeEach(inject());

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

  1. it('should call register with allow', function(){
  2.   sampleProviderObj.configureOptions({allow:true});
  3.   expect(anotherProviderObj.register).toHaveBeenCalled();
  4.   expect(anotherProviderObj.register).toHaveBeenCalledWith(appConstantsObj.ALLOW);
  5. });<

Вывод

Unit testing иногда кажется запутанным, но он стоит потраченного времени на изучение, так как Unit тесты обеспечивают правильность работы приложения. AngularJS облегчает Unit тестирование кода, написанного на данном фреймворке.

Автор - Ravi Kiran
Оригинал статьи