AngularJS 中的单元测试 - 模拟服务和承诺

Unit testing in AngularJS - Mocking Services and Promises

本文关键字:服务 承诺 模拟 单元测试 AngularJS      更新时间:2023-09-26
在Angular

中,一切似乎都有一个陡峭的学习曲线,对Angular应用程序进行单元测试绝对无法逃脱这种范式。

当我开始使用TDD和Angular时,我觉得我花了两倍(也许更多)的时间弄清楚如何测试,甚至可能更多的时间只是正确设置我的测试。但正如本·纳德尔(Ben Nadel)在他的博客中所说的那样,角度学习过程中有起有落。他的图表绝对是我对Angular的经验。

然而,随着我在学习 Angular 和单元测试方面也取得了进步,现在我觉得我花在设置测试上的时间少得多,而花在让测试从红色变成绿色上的时间要多得多——这是一种很好的感觉。

因此,我

遇到了设置单元测试以模拟服务和承诺的不同方法,我想我会分享我所学到的知识,并提出以下问题:

还有其他或更好的方法可以做到这一点吗?

所以在代码上,无论如何我们都来这里的目的 - 不是听某个人谈论他的爱,错误地学习一个框架。

这就是我开始嘲笑我的服务和承诺的方式,我将使用控制器,但服务和承诺显然可以在其他地方被嘲笑。

describe('Controller: Products', function () {
    var//iable declarations
        $scope,
        $rootScope,
        ProductsMock = {
            getProducts: function () {
            } // There might be other methods as well but I'll stick to one for the sake of consiseness
        },
        PRODUCTS = [{},{},{}]
    ;
    beforeEach(function () {
        module('App.Controllers.Products');
    });
    beforeEach(inject(function ($controller, _$rootScope_) {
        //Set up our mocked promise
        var promise = { then: jasmine.createSpy() };
        //Set up our scope
        $rootScope = _$rootScope_;
        $scope = $rootScope.$new();
        //Set up our spies
        spyOn(ProductsMock, 'getProducts').andReturn(promise);
        //Initialize the controller
        $controller('ProductsController', {
            $scope: $scope,
            Products: ProductsMock
        });
        //Resolve the promise
        promise.then.mostRecentCall.args[0](PRODUCTS);
    }));
    describe('Some Functionality', function () {
        it('should do some stuff', function () {
            expect('Stuff to happen');
        });
    });
});

对我们来说,这奏效了,但随着时间的推移,我认为一定有更好的方法。对于一个我讨厌

promise.then.mostRecentCall 

如果我们想重新初始化控制器,那么我们必须将其从 beforeEach 块中拉出,并将其单独注入到每个测试中。

必须有更好的方法...

现在我问是否有人有其他方法来设置测试,或者对我选择的方式有想法或感觉?

然后我遇到了另一篇文章,博客,stackoverflow示例(你选择它我可能在那里),我看到了$q库的使用。咄!当我们可以使用 Angular 给我们的工具时,为什么要设置一个完整的模拟承诺。我们的代码看起来更好,看起来更有意义 - 没有丑陋的承诺。

单元测试迭代的下一个是这样的:

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');
        });
    });
});

这开始感觉像是它应该设置的方式。我们可以嘲笑我们需要嘲笑的东西,我们可以设定我们的承诺来解决和拒绝,这样我们就可以真正测试两种可能的结果。这感觉很好...

你自己回答中关于使用$q.defer的要点听起来不错。我唯一的补充是

setupController(0, true)

不是特别清楚,由于参数 0true ,然后是使用它的 if 语句。此外,将 products 的模拟传递到 $controller 函数本身似乎不寻常,这意味着您可能有 2 种不同的products服务可用。一个直接注入控制器,一个由通常的 Angular DI 系统注入其他服务。我认为最好使用 $provide 来注入模拟,然后 Angular 中的任何地方对于任何测试都有相同的实例。

综上所述,下面这样的东西似乎更好,可以在 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);
    });
  });
});

请注意,缺少if语句,以及将getProductsDeferred对象限制在describe块的作用域getProducts模拟。使用这种模式,意味着您可以在其他products方法上添加其他测试,而不会污染模拟products对象或您拥有的setupController函数,以及测试所需的所有可能方法/组合。

作为侧边栏,我注意到:

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

意味着您将控制器和服务分离到不同的 Angular 模块中。我知道某些博客推荐了这个,但我怀疑这过于复杂了,每个应用程序一个模块是可以的。如果你然后重构,并使服务和指令完全分离可重用的组件,那么是时候将它们放入一个单独的模块中,并像使用任何其他第三方模块一样使用它们。

编辑:更正了$provide.value $provide.provide,并修复了控制器/服务实例化的一些顺序,并添加了指向Plunkr的链接