如何使用DOM操作正确地对测试指令进行单元化?

时间:2015-05-29 09:01:14

标签: angularjs unit-testing dom angularjs-directive

在问我真正的问题之前,我有一个不同的...在Angular指令中单元测试 DOM操作是否有意义?

例如,这是我的完整链接功能:

function linkFn(scope, element) {
    var ribbon = element[0];
    var nav = ribbon.children[0];

    scope.ctrl.ribbonItemClick = function (index) {
        var itemOffsetLeft;
        var itemOffsetRight;
        var item;

        if (scope.ctrl.model.selectedIndex === index) {
            return;
        }

        scope.ctrl.model.selectedIndex = index;

        item = nav.querySelectorAll('.item')[index];

        itemOffsetLeft = item.offsetLeft - ribbon.offsetLeft;
        itemOffsetRight = itemOffsetLeft + item.clientWidth;

        if (itemOffsetLeft < nav.scrollLeft) {
            nav.scrollLeft = itemOffsetLeft - MAGIC_PADDING;
        }

        if(itemOffsetRight > nav.clientWidth + nav.scrollLeft) {
            nav.scrollLeft = itemOffsetRight - nav.clientWidth + MAGIC_PADDING;
        }

        this.itemClick({
            item: scope.ctrl.model.items[index],
            index: index
        });

        $location.path(scope.ctrl.model.items[index].href);
    };

    $timeout(function $timeout() {
        var item = nav.querySelector('.item.selected');
        nav.scrollLeft = item.offsetLeft - ribbon.offsetLeft - MAGIC_PADDING;
    });
}

这适用于可滚动的选项卡式组件,我不知道如何测试nav.scrollLeft = x的3个实例。

当单击一个项目(仅部分可见)时,会发生前两个if语句。左/右(每个if)项目将被捕捉到组件的左/右边框。

第三个是,如果在加载组件时它不可见,则将所选项目置于视图中。

如何使用Karma / Jasmine进行单元测试。这样做是否有意义,或者我应该使用Protractor进行功能测试吗?

2 个答案:

答案 0 :(得分:13)

在测试指令时,查找设置或返回显式值的内容。这些通常很容易断言,用Jasmine和Karma对它们进行单元测试是有意义的。

看看Angular的tests for ng-src。在这里,他们通过断言元素的src属性设置为正确的值来测试该指令的工作原理。它是明确的:src属性具有特定值,或者不具有特定值。

it('should not result empty string in img src', inject(function($rootScope, $compile) {
  $rootScope.image = {};
  element = $compile('<img ng-src="{{image.url}}">')($rootScope);
  $rootScope.$digest();
  expect(element.attr('src')).not.toBe('');
  expect(element.attr('src')).toBe(undefined);
}));

ng-bind相同。在这里,他们将一串HTML传递给$ compiler,然后断言返回值的HTML填充了实际的范围值。再次,它是明确的。

it('should set text', inject(function($rootScope, $compile) {
  element = $compile('<div ng-bind="a"></div>')($rootScope);
  expect(element.text()).toEqual('');
  $rootScope.a = 'misko';
  $rootScope.$digest();
  expect(element.hasClass('ng-binding')).toEqual(true);
  expect(element.text()).toEqual('misko');
}));

当您进入更复杂的场景,例如针对视口可见性进行测试或测试特定元素是否位于页面上的正确位置时,您可以尝试测试CSS和style属性是否已正确设置,但是变得非常快速,不推荐。此时你应该看看量角器或类似的e2e测试工具。

答案 1 :(得分:7)

我会100%想要测试你指令的所有路径,即使它不是最容易的事情。但是你可以采取一些方法来简化这个过程。

将复杂的逻辑分解为服务

对我来说,第一件事是关于设置导航scrollLeft的复杂逻辑。为什么不把它分解成一个单独的服务,可以自己进行单元测试?

app.factory('AutoNavScroller', function() {
  var MAGIC_PADDING;
  MAGIC_PADDING = 25;

  return function(extraOffsetLeft) {

    this.getScrollPosition = function(item, nav) {
      var itemOffsetLeft, itemOffsetRight;

      itemOffsetLeft = item.offsetLeft - extraOffsetLeft;
      itemOffsetRight = itemOffsetLeft + item.clientWidth;

      if ( !!nav && itemOffsetRight > nav.clientWidth + nav.scrollLeft) {

        return itemOffsetRight - nav.clientWidth + MAGIC_PADDING;


      } else {

        return itemOffsetLeft - MAGIC_PADDING;

      }
    };
  }
});

这样可以更容易地测试所有路径和重构(您可以看到我能够在上面进行测试。测试可以在下面看到:

describe('AutoNavScroller', function() {
  var AutoNavScroller;

  beforeEach(module('app'));

  beforeEach(inject(function(_AutoNavScroller_) {
    AutoNavScroller = _AutoNavScroller_;
  }));

  describe('#getScrollPosition', function() {
    var scroller, item;

    function getScrollPosition(nav) {
      return scroller.getScrollPosition(item, nav);
    }

    beforeEach(function() {
      scroller = new AutoNavScroller(50);
      item = {
        offsetLeft: 100
      };
    })

    describe('with setting initial position', function() {
      it('gets the initial scroll position', function() {
        expect(getScrollPosition()).toEqual(25);
      });
    });

    describe('with item offset left of the nav scroll left', function() {
      it('gets the scroll position', function() {
        expect(getScrollPosition({
          scrollLeft: 100
        })).toEqual(25);
      });
    });

    describe('with item offset right of the nav width and scroll left', function() {
      beforeEach(function() {
        item.clientWidth = 300;
      });

      it('gets the scroll position', function() {
        expect(getScrollPosition({
          scrollLeft: 25,
          clientWidth: 50
        })).toEqual(325);
      });
    });
  });
});

测试该指令正在呼叫服务

既然我们已经破坏了我们的指令,我们就可以注入服务并确保它被正确调用。

app.directive('ribbonNav', function(AutoNavScroller, $timeout) {
  return {
    link: function(scope, element) {
      var navScroller;
      var ribbon = element[0];
      var nav = ribbon.children[0];

      // Assuming ribbon offsetLeft remains the same
      navScroller = new AutoNavScroller(ribbon.offsetLeft);

      scope.ctrl.ribbonItemClick = function (index) {

        if (scope.ctrl.model.selectedIndex === index) {
            return;
        }

        scope.ctrl.model.selectedIndex = index;

        item = nav.querySelectorAll('.item')[index];

        nav.scrollLeft = navScroller.getScrollLeft(item, nav);
        // ...rest of directive
      };

      $timeout(function $timeout() {
        var item = nav.querySelector('.item.selected');

        // Sets initial nav scroll left
        nav.scrollLeft = navScroller.getScrollLeft(item);
      });

    }
  }
});

确保我们的指令继续使用该服务的最简单方法是监视它将调用的方法并确保它们接收正确的参数:

describe('ribbonNav', function() {
  var $compile, $el, $scope, AutoNavScroller;

  function createRibbonNav() {
    $el = $compile($el)($scope);
    angular.element(document)
    $scope.$digest();
    document.body.appendChild($el[0]);
  }

  beforeEach(module('app'));

  beforeEach(module(function ($provide) {
    AutoNavScroller = jasmine.createSpy();
    AutoNavScroller.prototype.getScrollLeft = function(item, nav) {
      return !nav ? 50 : 100;
    };
    spyOn(AutoNavScroller.prototype, 'getScrollLeft').and.callThrough();

    $provide.provider('AutoNavScroller', function () {
      this.$get = function () {
        return AutoNavScroller;
      }
    });
  }));

  beforeEach(inject(function(_$compile_, $rootScope) {
    $compile = _$compile_;
    $el = "<div id='ribbon_nav' ribbon-nav><div style='width:50px;overflow:scroll;float:left;'><div class='item selected' style='height:100px;width:200px;float:left;'>An Item</div><div class='item' style='height:100px;width:200px;float:left;'>An Item</div></div></div>";
    $scope = $rootScope.$new()
    $scope.ctrl = {
      model: {
        selectedIndex: 0
      }
    };
    createRibbonNav();
  }));

  afterEach(function() {
    document.getElementById('ribbon_nav').remove();
  });

  describe('on link', function() {
    it('calls AutoNavScroller with selected item', inject(function($timeout) {
      expect(AutoNavScroller).toHaveBeenCalledWith(0);
    }));

    it('calls AutoNavScroller with selected item', inject(function($timeout) {
      $timeout.flush();
      expect(AutoNavScroller.prototype.getScrollLeft)
        .toHaveBeenCalledWith($el[0].children[0].children[0]);
    }));

    it('sets the initial nav scrollLeft', inject(function($timeout) {
      $timeout.flush();
      expect($el[0].children[0].scrollLeft).toEqual(50);
    }));
  });

  describe('ribbonItemClick', function() {
    beforeEach(function() {
      $scope.ctrl.ribbonItemClick(1);
    });

    it('calls AutoNavScroller with item', inject(function($timeout) {
      expect(AutoNavScroller.prototype.getScrollLeft)
        .toHaveBeenCalledWith($el[0].children[0].children[1], $el[0].children[0]);
    }));

    it('sets the nav scrollLeft', function() {
      expect($el[0].children[0].scrollLeft).toEqual(100);
    });
  });
});

现在,显然这些规格可以通过100种方式进行重构,但是你可以看到,一旦我们开始打破复杂的逻辑,更高的覆盖范围就更容易实现。围绕模拟对象存在一些风险,因为它可能会使您的测试变得脆弱,但我相信这里的权衡是值得的。另外我绝对可以看到AutoNavScroller被推广并在其他地方重复使用。如果代码存在于之前的指令中,这是不可能的。

结论

无论如何,我认为Angular很棒的原因是能够测试这些指令以及它们如何与DOM交互。这些茉莉花规格可以在任何浏览器中运行,并且会很快出现不一致或回归。

此外,这是一个plunkr,所以你可以看到所有移动的部分和实验:http://plnkr.co/edit/wvj4TmmJtxTG0KW7v9rn?p=preview