如何使用 Sinon.js 测试 Angular $modal?

How to test Angular $modal using Sinon.js?

我正在尝试为 AngularJS 中的 $modal 编写单元测试。模态代码位于控制器中,如下所示:

$scope.showProfile = function(user){
                var modalInstance = $modal.open({
                templateUrl:"components/profile/profile.html",
                resolve:{
                    user:function(){return user;}
                },
                controller:function($scope,$modalInstance,user){$scope.user=user;}
            });
        };

函数在 HTML 中的 ng-repeat 中的按钮上调用如下:

 <button class='btn btn-info' showProfile(user)'>See Profile</button>

如您所见,用户已传入并在模式中使用,然后数据将绑定到其 HTML 中的配置文件部分。

我正在使用 Karma-Mocha 和 Karma-Sinon 来尝试执行单元测试,但我不明白如何实现这一点,我想验证传入的用户是否与解析中使用的用户相同模态参数。

我看到了一些如何使用 Jasmine 执行此操作的示例,但我无法将它们转换为 mocha + sinon 测试。

这是我的尝试:

设置代码:

describe('Unit: ProfileController Test Suite,', function(){
beforeEach(module('myApp'));

var $controller, modalSpy, modal, fakeModal;

fakeModal  = {// Create a mock object using spies
    result: {
        then: function (confirmCallback, cancelCallback) {
            //Store the callbacks for later when the user clicks on the OK or Cancel button of the dialog
            this.confirmCallBack = confirmCallback;
            this.cancelCallback = cancelCallback;
        }
    },
    close: function (item) {
        //The user clicked OK on the modal dialog, call the stored confirm callback with the selected item
        this.result.confirmCallBack(item);
    },
    dismiss: function (type) {
        //The user clicked cancel on the modal dialog, call the stored cancel callback
        this.result.cancelCallback(type);
    }
};

var modalOptions = {
    templateUrl:"components/profile/profile.html",
    resolve:{
        agent:sinon.match.any //No idea if this is correct, trying to match jasmine.any(Function)
    },
    controller:function($scope,$modalInstance,user){$scope.user=user;}
};

var actualOptions;

beforeEach(inject(function(_$controller_, _$modal_){
    // The injector unwraps the underscores (_) from around the parameter names when matching
    $controller = _$controller_;
    modal = _$modal_;
    modalSpy = sinon.stub(modal, "open");
    modalSpy.yield(function(options){ //Doesn't seem to be correct, trying to match Jasmines callFake function but get this error - open cannot yield since it was not yet invoked.
        actualOptions = options;
        return fakeModal;
    });
}));

var $scope, controller;

beforeEach(function() {
    $scope = {};

    controller = $controller('profileController', {
        $scope: $scope,
        $modal: modal
    });

});

afterEach(function () {
    modal.open.restore();
});

实测:

describe.only('display a user profile', function () {
        it('user details should match those passed in', function(){
            var user= { name : "test"};
            $scope.showProfile(user);

            expect(modalSpy.open.calledWith(modalOptions)).to.equal(true); //Always called with empty
            expect(modalSpy.open.resolve.user()).to.equal(user); //undefined error - cannot read property resolve of undefined
        });
    });

我的测试设置和实际测试基于我遇到的 Jasmine 代码,并试图将其转换为 Mocha + SinonJS 代码,我对 AngularJS 和编写单元测试都是新手,所以我希望我只需要朝着正确的方向轻推。

任何人都可以分享使用 Mocha + SinonJS 而不是 Jasmine 时的正确方法吗?

这将是一个 的答案,涉及单元测试、存根和 sinon.js(在某种程度上)。

(如果您想向前跳,向下滚动到#3 标题之后,查看您的规范的最终实现)

1。确立目标

I want to verify that the user getting passed in is the same one used in the resolve parameter of the modal.

太好了,我们有了目标。

$modal.openresolve { user: fn }的return值,应该是我们传入$scope.showProfile方法的用户。

鉴于 $modal 在您的实现中是一个 外部依赖项 ,我们只是 不关心 的内部实现$modal。显然我们不想将真正的 $modal 服务注入到我们的测试套件中。

看过你的测试套件后,你似乎已经掌握了它(太棒了!)所以我们不必过多地讨论其背后的原因 .

我想最初的期望措辞会令人痛苦:

$modal.open should have been invoked, and its resolve.user function should return the user passed to $scope.showProfile.

2。准备

我现在要从您的测试套件中删除很多内容,以使其更具可读性。如果缺少对规范通过至关重要的部分,我深表歉意。

beforeEach

我将从简化 beforeEach 块开始。每个 describe 块有一个 beforeEach 块会更简洁,它提高了可读性并减少了样板代码。

您的简化 beforeEach 块可能看起来像这样:

var $scope, $modal, createController; // [1]: createController(?)

beforeEach(function () {
  $modal = {}; // [2]: empty object? 

  module('myApp', function ($provide) {
    $provide.value('$modal', $modal); // [3]: uh? 
  });

  inject(function ($controller, $injector) { // [4]: $injector? 
    $scope = $injector.get('$rootScope').$new();
    $modal = $injector.get('$modal');

    createController = function () { // [5(1)]: createController?!
      return $controller('profileController', {
        $scope: $scope
        $modal: $modal
      });
    };
  });

  // Mock API's
  $modal.open = sinon.stub(); // [6]: sinon.stub()? 
});

所以,关于我的一些笔记 added/changed:

[1]: createController 是我们公司在为 angular 控制器编写单元测试时已经建立了很长一段时间的东西。它为您提供了很大的灵活性,可以根据规范修改所述控制器的依赖性。

假设您在控制器实现中有以下内容:

.controller('...', function (someDependency) {
  if (!someDependency) {
    throw new Error('My super important dependency is missing!');  
  }

  someDependency.doSomething();
});

如果您想为 throw 编写测试,但您放弃了 createController 方法 - 您需要设置一个单独的 describe 块,它有自己的 beforeEach|before 调用设置 someDependency = undefined麻烦大了!

有了"delayed $inject",就这么简单:

it('throws', function () {
  someDependency = undefined;

  function fn () {
    createController();
  }

  expect(fn).to.throw(/dependency missing/i);
});

[2]: empty object 通过在 beforeEach 块的开头用空 object 覆盖全局变量,我们可以确定之前规范中的任何遗留方法都是 dead.


[3]: $provide 通过$providing mocked出来的(此时为空)object作为值给我们的module,我们不必加载包含 $modal 的实际实现的模块。

本质上,这使得单元测试 angular 代码 a breeze,因为你 never 运行 再次进入单元测试中的 Error: $injector:unpr Unknown Provider,通过简单地删除对 un-interesting 代码的任何和所有引用,以进行灵活、集中的单元测试。


[4]: $injector 我更喜欢使用 $injector,因为它减少了需要提供给 inject() 方法的参数数量几乎没有。在这里随心所欲!


[5]:createController 阅读 #1。


[6]: sinon.stubbeforeEach 块的末尾,我建议您提供所有已删除的依赖项必要的方法。淘汰的方法。

如果您确定一个被删除的方法会并且应该总是return,说一个已解决的承诺 - 你可以将此行更改为:

dependency.mockedFn = sinon.stub().returns($q.when());
// dont forget to expose, and $inject -> $q!

但是,一般来说,我会建议在个人 it() 中使用明确的 return 语句。

3。编写规范

好的,回到手头的问题。

鉴于前面提到的 beforeEach 块,您的 describe/it 可能看起来像这样:

describe('displaying a user profile', function () {
  it('matches the passed in user details', function () {
    createController();
  });
});

有人会认为我们需要以下内容:

  • 一个用户object.
  • 调用 $scope.showProfile
  • return 值 解析函数 的期望 调用 $modal.open.

问题在于测试我们无法控制的东西的概念。 $modal.open() 在幕后所做的事情不在您的控制器规范套件的范围内 - 它是一个依赖项,并且依赖项被删除。

然而,我们可以测试我们的控制器是否使用正确的参数调用了 $modal.open,但是 resolvecontroller 之间的关系不是这个规范套件的一部分 (稍后会详细介绍)。

所以要修改我们的需求:

  • 一个用户object.
  • 调用 $scope.showProfile
  • 参数 的期望传递给 $modal.open.

it('calls $modal.open with the correct params', function () {
  // Preparation
  var user = { name: 'test' };
  var expected = {
    templateUrl: 'components/profile/profile.html',
    resolve: {
      user: sinon.match(function (value) {
        return value() === user;
      }, 'boo!')
    },
    controller: sinon.match.any        
  };

  // Execution
  createController();
  $scope.showProfile(user);

  // Expectation
  expect($modal.open).to.have
    .been.calledOnce
    .and.calledWithMatch(expected);
});

I want to verify that the user getting passed in is the same one used in the resolve parameter of the modal.

"$modal.open should have been instantiated, and its resolve.user function should return the user passed to $scope.showProfile."

我会说我们的规范完全涵盖了这一点 - 我们有 'cancelled out' $modal 来启动。甜蜜

custom matchers taken from the sinonjs docs的解释。

Custom matchers are created with the sinon.match factory which takes a test function and an optional message. The test function takes a value as the only argument, returns true if the value matches the expectation and false otherwise. The message string is used to generate the error message in case the value does not match the expectation.

本质上;

sinon.match(function (value) {
  return /* expectation on the behaviour/nature of value */
}, 'optional_message');

如果您绝对想测试 resolve 的 return 值(最终在 $modal controller 中的值),我建议您单独测试控制器将其提取到命名控制器,而不是匿名函数。

$modal.open({
  // controller: function () {},
  controller: 'NamedModalController'
});

通过这种方式,您可以像这样编写对模态控制器的期望(当然是在另一个规范文件中):

it('exposes the resolved {user} value onto $scope', function () {
  user = { name: 'Mike' };
  createController();
  expect($scope).to.have.property('user').that.deep.equals(user);
});

现在,其中很多是 re-iteration - 您已经做了很多我提到的事情,希望我不会成为一个工具。

我提议的 it() 中的一些准备数据可以移到 beforeEach 块中 - 但我建议只有在有大量测试调用相同代码时才这样做。

保持规格套件干爽并不像保持规格明确那么重要,这样可以避免在其他开发人员过来阅读它们并修复一些回归错误时造成任何混淆。


最后,您在原文中写的一些内联评论:

sinon.match.any

var modalOptions = {
  resolve:{
    agent:sinon.match.any // No idea if this is correct, trying to match jasmine.any(Function)
  },
};

如果你想将它与一个函数匹配,你会这样做:

sinon.match.func 相当于 jasmine.any(Function)

sinon.match.any 匹配 任何东西


sinon.stub.yield([arg1, arg2])

// open cannot yield since it was not yet invoked.
modalSpy.yield(function(options){ 
  actualOptions = options;
  return fakeModal;
});

首先,您在 $modal 上有多个方法(或应该)被删除。因此,我认为在 modalSpy 下屏蔽 $modal.open 是个坏主意——关于 yield 的方法不是很明确。

其次,当将存根引用为 modalSpy 时,您将 spystub 混合使用(我一直这样做...)。

A spy 包装了原始功能并将其保留下来,记录所有 'events' 以备将来的期望,仅此而已。

A stub 实际上是 spy,不同之处在于我们可以通过提供 .returns().throws() 等来改变所述函数的行为。简而言之;一个充满活力的间谍。

如错误消息所示,该函数只有在被调用后才能 yield

  it('yield / yields', function () {
    var stub = sinon.stub();

    stub.yield('throwing errors!'); // will crash...
    stub.yields('y');

    stub(function () {
      console.log(arguments);
    });

    stub.yield('x');
    stub.yields('ohno'); // wont happen...
  });

如果我们要从此规范中删除 stub.yield('throwing errors!'); 行,输出将如下所示:

LOG: Object{0: 'y'}
LOG: Object{0: 'x'}

简短而有趣(关于 yield/yields 我所知道的差不多);

  • yield 在调用 stub/spy 回调之后。
  • yields 在调用 stub/spy 回调之前。

如果您已经读到这里,您可能已经意识到我可以就这个主题滔滔不绝地讲上几个小时。幸运的是我累了,是时候闭上眼睛了。


一些与主题松散相关的资源: