如何正确地对具有DOM操作的指令进行单元测试?

17

在问我的真正问题之前,我有一个不同的问题...... 在 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的三个实例。

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

第三个是为了在组件加载时将选定的项目放置在视图中(如果它不可见)。

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


这是一个很广泛的问题,可能取决于个人喜好。至于我和公司里的同事们,我们单元测试控制器/服务/工厂以验证正确的数据操作,并进行端到端测试以测试其外观是否正确。 - maurycy
1
覆盖率怎么样?在这些情况下你只是忽略它吗? - rfgamaral
2个回答

12

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

看一下Angular关于 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属性是否被正确设置,但这很容易出错,不建议这样做。此时,你应该考虑使用Protractor或类似的端到端测试工具。


我们已经使用Protractor进行整个网站的端到端测试,但我只是想研究一下单元测试那段代码,以便实现100%的测试覆盖率。Protractor将有助于测试该指令是否正常工作,但也有助于实现单元测试覆盖率。 - rfgamaral

6
我会非常愿意测试您指令的所有路径,即使这并不容易。但是,有一些方法可以使这个过程变得更简单。
将复杂的逻辑拆分为服务
我首先注意到的是设置导航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的交互。这些Jasmine规范可以在任何浏览器中运行,并将快速显示不一致性或回归。

此外,这是一个plunkr,您可以在其中查看所有组件并进行实验:http://plnkr.co/edit/wvj4TmmJtxTG0KW7v9rn?p=preview


5
不冒犯,但我完全不同意这种方法。我的意思是,为了测试而创建服务。我认为这是为了测试而妥协简单且好的解决方案,我个人不喜欢这样。此外,您创建的服务很可能永远不会被其他指令或其他内容使用,它只适用于这个指令。在某种程度上,您正在将内部实现细节暴露给具有公共API的服务,仅用于测试目的,这让我感到不适。但我仍然感谢您的意见。谢谢。 - rfgamaral
1
好的,说得对,它确实不只是为了测试而是为了将一些复杂的逻辑模块化。尽管它还不错,但我肯定能看出留下它的理由。我仍然会尝试测试所有路径,但是不会使用模拟,而是手动设置元素属性。祝你好运。 - rsnorman15
我真的不认为那些代码片段是“复杂逻辑”。我完全支持模块化,但在这种特定情况下,我不认为有必要,因为那个逻辑是此功能区指令的内部实现细节,而且没有别的。我会尝试另一种方法,但不确定自己对它的感觉如何。 - rfgamaral

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