Unit testing in AngularJS - Mocking Services and Promises

后端 未结 2 1632
栀梦
栀梦 2020-12-08 16:31

In Angular everything seems to have a steep learning curve and unit testing an Angular app definitely doesn\'t escape this paradigm.

When I started with TDD and Ang

相关标签:
2条回答
  • 2020-12-08 17:04

    Then I came across another post, blog, stackoverflow example (you pick it I was probably there), and I saw the use of the $q library. Duh! Why set up a whole mock promise when we can just use the tool that Angular gives us. Our code looks nicer and makes more sense to look at - no ugly promise.then.mostRecent thing.

    Next in the iteration of unit testing was this:

    describe('Controller: Products', function () {
        var//iable declarations
            $scope,
            $rootScope,
            $q,
            $controller,
            productService,
            PROMISE = {
                resolve: true,
                reject: false
            },
            PRODUCTS = [{},{},{}] //constant for the products that are returned by the service
        ;
    
        beforeEach(function () {
            module('App.Controllers.Products');
            module('App.Services.Products');
        });
    
    
        beforeEach(inject(function (_$controller_, _$rootScope_, _$q_, _products_) {
            $rootScope = _$rootScope_;
            $q = _$q_;
            $controller = _$controller_;
            productService = _products_;
            $scope = $rootScope.$new();
        }));
    
        function setupController(product, resolve) {
            //Need a function so we can setup different instances of the controller
            var getProducts = $q.defer();
    
            //Set up our spies
            spyOn(products, 'getProducts').andReturn(getProducts.promise);
    
            //Initialise the controller
            $controller('ProductsController', {
                $scope: $scope,
                products: productService
            });
    
            // Use $scope.$apply() to get the promise to resolve on nextTick().
            // Angular only resolves promises following a digest cycle,
            // so we manually fire one off to get the promise to resolve.
            if(resolve) {
                $scope.$apply(function() {
                    getProducts.resolve();
                });
            } else {
                $scope.$apply(function() {
                    getProducts.reject();
                });
            }
        }
    
        describe('Resolving and Rejecting the Promise', function () {
            it('should return the first PRODUCT when the promise is resolved', function () {
                setupController(PRODUCTS[0], PROMISE.resolve); // Set up our controller to return the first product and resolve the promise. 
                expect('to return the first PRODUCT when the promise is resolved');
            });
    
            it('should return nothing when the promise is rejected', function () {
                setupController(PRODUCTS[0], PROMISE.reject); // Set up our controller to return first product, but not to resolve the promise. 
                expect('to return nothing when the promise is rejected');
            });
        });
    });
    

    This started to feel like the way it should be set up. We can mock what we need to mock we can set our promise to resolve and reject so we can truly test the two possible outcomes. This feels good...

    0 讨论(0)
  • 2020-12-08 17:30

    The main point in your own answer about using $q.defer sounds good. My only additions would be that

    setupController(0, true)
    

    is not particularly clear, due to the parameters 0 and true, and then the if statement that uses this. Also, passing the mock of products into the $controller function itself seems unusual, and means you might have 2 different products services available. One directly injected into the controller, and one injected by the usual Angular DI system into other services. I think better to use $provide to inject mocks and then everywhere in Angular will have the same instance for any test.

    Putting this all together, something like the following seems better, which can be seen at http://plnkr.co/edit/p676TYnAIb9QlD7MPIHu?p=preview

    describe('Controller: ProductsController', function() {
    
      var PRODUCTS, productsMock,  $rootScope, $controller, $q;
    
      beforeEach(module('plunker'));
    
      beforeEach(module(function($provide){
        PRODUCTS = [{},{},{}]; 
        productsMock = {};        
        $provide.value('products', productsMock);
      }));
    
      beforeEach(inject(function (_$controller_, _$rootScope_, _$q_, _products_) {
        $rootScope = _$rootScope_;
        $q = _$q_;
        $controller = _$controller_;
        products = _products_;
      }));
    
      var createController = function() {
        return $controller('ProductsController', {
          $scope: $rootScope
        })
      };
    
      describe('on init', function() {
        var getProductsDeferred;
    
        var resolve = function(results) {
          getProductsDeferred.resolve(results);
          $rootScope.$apply();
        }
    
        var reject = function(reason) {
          getProductsDeferred.reject(reason);
          $rootScope.$apply();
        }
    
        beforeEach(function() {
          getProductsDeferred = $q.defer();
          productsMock.getProducts = function() {
            return getProductsDeferred.promise;
          };
          createController();
        });
    
        it('should set success to be true if resolved with product', function() {
          resolve(PRODUCTS[0]);
          expect($rootScope.success).toBe(true);
        });
    
        it('should set success to be false if rejected', function() {
          reject();
          expect($rootScope.success).toBe(false);
        });
      });
    });
    

    Notice that lack of if statement, and the limitation of the getProductsDeferred object, and getProducts mock, to the scope of a describe block. Using this sort of pattern, means you can add other tests, on other methods of products, without polluting the mock products object, or the setupController function you have, with all the possible methods / combinations you need for the tests.

    As a sidebar, I notice:

    module('App.Controllers.Products');
    module('App.Services.Products');
    

    means you are separating your controllers and services into different Angular modules. I know certain blogs have recommended this, but I suspect this overcomplicated things, and a single module per app is ok. If you then refactor, and make services and directives completely separate reusable components, then it would be time to put them into a separate module, and use them as you would any other 3rd party module.

    Edit: Corrected $provide.provide to $provide.value, and fixed some of the ordering of instantiation of controller/services, and added a link to Plunkr

    0 讨论(0)
提交回复
热议问题