使用success()和error()测试控制器

22

我正在尝试找出在控制器中单元测试成功和错误回调的最佳方法。只要控制器仅使用默认的$q函数(例如以下示例),我就可以模拟服务方法。但是当控制器响应“success”或“error”Promise时,我遇到了问题。(如果我的术语不正确,请原谅)。

以下是一个示例控制器/服务:

var myControllers = angular.module('myControllers');

myControllers.controller('SimpleController', ['$scope', 'myService',
  function ($scope, myService) {

      var id = 1;
      $scope.loadData = function () {
          myService.get(id).then(function (response) {
              $scope.data = response.data;
          });
      };

      $scope.loadData2 = function () {
          myService.get(id).success(function (response) {
              $scope.data = response.data;
          }).error(function(response) {
              $scope.error = 'ERROR';
          });
      }; 
  }]);


cocoApp.service('myService', [
    '$http', function($http) {
        function get(id) {
            return $http.get('/api/' + id);
        }
    }
]);  

我有以下测试。
'use strict';

describe('SimpleControllerTests', function () {

    var scope;
    var controller;
    var getResponse = { data: 'this is a mocked response' };

    beforeEach(angular.mock.module('myApp'));

    beforeEach(angular.mock.inject(function($q, $controller, $rootScope, $routeParams){

        scope = $rootScope;
        var myServiceMock = {
            get: function() {}
        };

        // setup a promise for the get
        var getDeferred = $q.defer();
        getDeferred.resolve(getResponse);
        spyOn(myServiceMock, 'get').andReturn(getDeferred.promise);

        controller = $controller('SimpleController', { $scope: scope, myService: myServiceMock });
    }));


    it('this tests works', function() {
        scope.loadData();
        expect(scope.data).toEqual(getResponse.data);
    });

    it('this doesnt work', function () {
        scope.loadData2();
        expect(scope.data).toEqual(getResponse.data);
    });
});

第一个测试通过了,而第二个测试失败了,报错为“TypeError: Object doesn't support property or method 'success'”。我理解在这种情况下,getDeferred.promise没有一个success函数。那么问题来了,有什么好的方法可以编写这个测试,这样我就可以测试模拟服务的“success”、“error”和“then”条件呢?
我开始觉得应该避免在我的控制器中使用success()和error()。
编辑:
所以经过进一步思考,再次感谢下面详细的答案,我得出了结论:在控制器中处理成功和错误回调是不好的。正如HackedByChinese在下面提到的,success\error是由$http添加的语法糖。实际上,尝试处理success\error会让$http的问题泄漏到我的控制器中,这正是我试图通过将$http调用包装在服务中避免的。我要采取的方法是修改控制器,不再使用success\error。
myControllers.controller('SimpleController', ['$scope', 'myService',
  function ($scope, myService) {

      var id = 1;
      $scope.loadData = function () {
          myService.get(id).then(function (response) {
              $scope.data = response.data;
          }, function (response) {
              $scope.error = 'ERROR';
          });
      };
  }]);

这样我可以通过在延迟对象上调用 resolve() 和 reject() 来测试错误和成功条件:
'use strict';

describe('SimpleControllerTests', function () {

    var scope;
    var controller;
    var getResponse = { data: 'this is a mocked response' };
    var getDeferred;
    var myServiceMock;

    //mock Application to allow us to inject our own dependencies
    beforeEach(angular.mock.module('myApp'));
    //mock the controller for the same reason and include $rootScope and $controller
    beforeEach(angular.mock.inject(function($q, $controller, $rootScope, $routeParams) {

        scope = $rootScope;
        myServiceMock = {
            get: function() {}
        };
        // setup a promise for the get
        getDeferred = $q.defer();
        spyOn(myServiceMock, 'get').andReturn(getDeferred.promise);
        controller = $controller('SimpleController', { $scope: scope, myService: myServiceMock });  
    }));

    it('should set some data on the scope when successful', function () {
        getDeferred.resolve(getResponse);
        scope.loadData();
        scope.$apply();
        expect(myServiceMock.get).toHaveBeenCalled();
        expect(scope.data).toEqual(getResponse.data);
    });

    it('should do something else when unsuccessful', function () {
        getDeferred.reject(getResponse);
        scope.loadData();
        scope.$apply();
        expect(myServiceMock.get).toHaveBeenCalled();
        expect(scope.error).toEqual('ERROR');
    });
});

使用 Angular Mocks(您已经在使用了),您可以使用 $httpBackend 来设置对 http 调用的期望并指定它们应该如何响应,然后您可以使用 flush 同步完成测试。为什么不走这条路呢?请参见:https://docs.angularjs.org/api/ngMock/service/$httpBackend - moribvndvs
谢谢,但我不知道那如何帮助。在这种情况下,我正在尝试对控制器进行单元测试,因此我完全模拟了正在进行 $http 调用的服务。在这个例子中,没有调用 $http 的代码,因为模拟服务被注入到控制器中。 - nixon
谢谢您教我将HTTP功能从控制器中分离出来,现在我看到了这一点,把它放在控制器里就没有意义了。 - a7omiton
另外补充一点,在Jasmine 2.3.1中,您需要在间谍上使用and.returnValue,而不是andReturn - a7omiton
3个回答

26

正如一个已被删除的答案中提到的,successerror 是由 $http 添加的语法糖,因此在创建自己的 Promise 时它们不存在。你有两个选择:

1 - 不模拟服务并使用 $httpBackend 来设置期望和刷新

这个想法是让你的 myService 像平常一样运行而不知道正在被测试。 $httpBackend 将允许你设置期望和响应,并刷新它们,以便你可以同步地完成测试。 $http 不会更聪明,它返回的 Promise 看起来和功能与真实的 Promise 相同。如果你有简单的测试并且只需要少量的 HTTP 期望,那么这个选项很好。

'use strict';

describe('SimpleControllerTests', function () {

    var scope;
    var expectedResponse = { name: 'this is a mocked response' };
    var $httpBackend, $controller;

    beforeEach(module('myApp'));

    beforeEach(inject(function(_$rootScope_, _$controller_, _$httpBackend_){ 
        // the underscores are a convention ng understands, just helps us differentiate parameters from variables
        $controller = _$controller_;
        $httpBackend = _$httpBackend_;
        scope = _$rootScope_;
    }));

    // makes sure all expected requests are made by the time the test ends
    afterEach(function() {
      $httpBackend.verifyNoOutstandingExpectation();
      $httpBackend.verifyNoOutstandingRequest();
    });

    describe('should load data successfully', function() {

        beforeEach(function() {
           $httpBackend.expectGET('/api/1').response(expectedResponse);
           $controller('SimpleController', { $scope: scope });

           // causes the http requests which will be issued by myService to be completed synchronously, and thus will process the fake response we defined above with the expectGET
           $httpBackend.flush();
        });

        it('using loadData()', function() {
          scope.loadData();
          expect(scope.data).toEqual(expectedResponse);
        });

        it('using loadData2()', function () {
          scope.loadData2();
          expect(scope.data).toEqual(expectedResponse);
        });
    });

    describe('should fail to load data', function() {
        beforeEach(function() {
           $httpBackend.expectGET('/api/1').response(500); // return 500 - Server Error
           $controller('SimpleController', { $scope: scope });
           $httpBackend.flush();
        });

        it('using loadData()', function() {
          scope.loadData();
          expect(scope.error).toEqual('ERROR');
        });

        it('using loadData2()', function () {
          scope.loadData2();
          expect(scope.error).toEqual('ERROR');
        });
    });           
});

2 - 返回一个完全模拟的 Promise

如果你要测试的东西有复杂的依赖关系,而且所有的设置都很麻烦,你可能仍然想像你已经尝试过的那样模拟服务和调用本身。不同的是,你希望完全模拟 Promise。这样做的缺点是可能需要创建所有可能的模拟 Promise,但是你可以通过创建自己的函数来简化这个过程。

这个方法之所以有效,是因为我们假装它通过立即调用 successerrorthen 提供的处理程序来解决,从而使其同步完成。

'use strict';

describe('SimpleControllerTests', function () {

    var scope;
    var expectedResponse = { name: 'this is a mocked response' };
    var $controller, _mockMyService, _mockPromise = null;

    beforeEach(module('myApp'));

    beforeEach(inject(function(_$rootScope_, _$controller_){ 
        $controller = _$controller_;
        scope = _$rootScope_;

        _mockMyService = {
            get: function() {
               return _mockPromise;
            }
        };
    }));

    describe('should load data successfully', function() {

        beforeEach(function() {

          _mockPromise = {
             then: function(successFn) {
               successFn(expectedResponse);
             },
             success: function(fn) {
               fn(expectedResponse);
             }
          };

           $controller('SimpleController', { $scope: scope, myService: _mockMyService });
        });

        it('using loadData()', function() {
          scope.loadData();
          expect(scope.data).toEqual(expectedResponse);
        });

        it('using loadData2()', function () {
          scope.loadData2();
          expect(scope.data).toEqual(expectedResponse);
        });
    });

    describe('should fail to load data', function() {
        beforeEach(function() {
          _mockPromise = {
            then: function(successFn, errorFn) {
              errorFn();
            },
            error: function(fn) {
              fn();
            }
          };

          $controller('SimpleController', { $scope: scope, myService: _mockMyService });
        });

        it('using loadData()', function() {
          scope.loadData();
          expect(scope.error).toEqual("ERROR");
        });

        it('using loadData2()', function () {
          scope.loadData2();
          expect(scope.error).toEqual("ERROR");
        });
    });           
});

我很少选择选项2,即使在大型应用程序中也是如此。

就这件事而言,你的loadDataloadData2 http处理程序存在错误。它们引用了response.data,但是处理程序将直接使用解析后的响应数据调用,而不是响应对象(因此应该使用data而不是response.data)。


谢谢您的回答,但我该如何测试错误条件呢?我开始认为在控制器之外明确使用成功和错误可能真的不是一个好主意。我想控制器代码应该是then(function(..success code here..), function(..error code here...)。然后我可以使用resolve()和reject()在测试中模拟成功\错误的情况。 - nixon
这就是为什么选项#2很糟糕。如果你想要一个错误条件,把$httpBackend设置和控制器初始化移到事实体的主体中,或者将其放在自己的describe块中,该块具有不同的beforeEach。我会用成功和失败更新我的示例。 - moribvndvs
好的,已更新。如果有错别字或小错误,请见谅,因为我必须赶着参加一场会议 :p - moribvndvs
非常感谢您详细的回答,这让我深思熟虑,我决定在我的控制器中不再使用成功和错误。尽管如此,您提供了一个完全可行的解决方案,我给您点赞。但是,我宁愿避免对实际服务实现的依赖。也许我太理想化了 :) - nixon
最终,使用你自己熟悉的方式即可。但是我一直这样看待它,当处理外部数据时,我会测试控制器,以确保在应该调用服务以加载或提交数据时,使用正确的参数,并且对于可能的响应做出正确的处理。通过在一个简单的位置 $httpBackend 进行控制,这是最接近真实情况的方法,同时在测试中易于实现和实用。当然,这取决于服务的功能,但真正必要的复杂服务是很少见的。 - moribvndvs

3

不要混淆职责!

在控制器中使用$httpBackend是一个糟糕的想法,因为它会让测试中混淆职责。无论您从终端节点检索数据与否,这都不是控制器关注的问题,而是所调用的DataService关注的问题。

如果您更改服务内的终端点URL,则可以更清楚地看到这一点。那么您就必须修改两个测试:服务测试和控制器测试。

此外,正如先前提到的,使用successerror是一种语法糖,我们应该坚持使用thencatch。但实际上,您可能发现需要测试“旧代码”。因此,我使用以下函数:

function generatePromiseMock(resolve, reject) {
    var promise;
    if(resolve) {
        promise = q.when({data: resolve});
    } else if (reject){
        promise = q.reject({data: reject});
    } else {
        throw new Error('You need to provide an argument');
    }
    promise.success = function(fn){
        return q.when(fn(resolve));
    };
    promise.error = function(fn) {
        return q.when(fn(reject));
    };
    return promise;
}

通过调用此函数,您将获得一个真实的承诺,以响应 thencatch 方法,并在需要时为 successerror 回调正常工作。请注意,成功和错误会返回一个承诺本身,因此它将与链接的 then 方法一起使用。

(注意:在第4行和第6行中,该函数在对象的数据属性内返回 resolve 和 reject 值。这是模拟 $http 的行为,因为它返回数据、http 状态等。)


有点晚了,但我想分享一下我的解决方案。如果您有不同意见,请随意提出 :) - Cesar Alvarado

0

是的,在控制器中不要使用 $httpbackend,因为我们不需要进行真实的请求,你只需要确保一个单元按预期执行其工作,看看这个简单的控制器测试,它很容易理解。

/**
 * @description Tests for adminEmployeeCtrl controller
 */
(function () {

    "use strict";

    describe('Controller: adminEmployeeCtrl ', function () {

        /* jshint -W109 */
        var $q, $scope, $controller;
        var empService;
        var errorResponse = 'Not found';


        var employeesResponse = [
            {id:1,name:'mohammed' },
            {id:2,name:'ramadan' }
        ];

        beforeEach(module(
            'loadRequiredModules'
        ));

        beforeEach(inject(function (_$q_,
                                    _$controller_,
                                    _$rootScope_,
                                    _empService_) {
            $q = _$q_;
            $controller = _$controller_;
            $scope = _$rootScope_.$new();
            empService = _empService_;
        }));

        function successSpies(){

            spyOn(empService, 'findEmployee').and.callFake(function () {
                var deferred = $q.defer();
                deferred.resolve(employeesResponse);
                return deferred.promise;
                // shortcut can be one line
                // return $q.resolve(employeesResponse);
            });
        }

        function rejectedSpies(){
            spyOn(empService, 'findEmployee').and.callFake(function () {
                var deferred = $q.defer();
                deferred.reject(errorResponse);
                return deferred.promise;
                // shortcut can be one line
                // return $q.reject(errorResponse);
            });
        }

        function initController(){

            $controller('adminEmployeeCtrl', {
                $scope: $scope,
                empService: empService
            });
        }


        describe('Success controller initialization', function(){

            beforeEach(function(){

                successSpies();
                initController();
            });

            it('should findData by calling findEmployee',function(){
                $scope.findData();
                // calling $apply to resolve deferred promises we made in the spies
                $scope.$apply();
                expect($scope.loadingEmployee).toEqual(false);
                expect($scope.allEmployees).toEqual(employeesResponse);
            });
        });

        describe('handle controller initialization errors', function(){

            beforeEach(function(){

                rejectedSpies();
                initController();
            });

            it('should handle error when calling findEmployee', function(){
                $scope.findData();
                $scope.$apply();
                // your error expectations
            });
        });
    });
}());

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接