Back to Repositories

Validating Anchor Scroll Navigation Behavior in Angular.js

This test suite validates the $anchorScroll functionality in AngularJS, focusing on scroll behavior and viewport positioning. It ensures proper anchor-based navigation and offset handling for improved user experience.

Test Coverage Overview

The test suite provides comprehensive coverage of $anchorScroll functionality:

  • Basic anchor scrolling between top and bottom elements
  • Custom yOffset implementation for precise scroll positioning
  • URL hash-based navigation scenarios
  • Viewport boundary conditions and overflow handling

Implementation Analysis

The testing approach utilizes Jasmine custom matchers and Protractor for E2E testing. It implements browser-specific viewport calculations and scroll position verification through custom JavaScript execution. The suite employs modular test organization with nested describe blocks for different feature sets.

Technical Details

  • Jasmine test framework with custom matchers (toBeInViewport, toHaveTop)
  • Protractor for browser automation
  • Browser executeScript() for DOM manipulation
  • Viewport size management utilities
  • Cross-browser compatibility handling

Best Practices Demonstrated

The test suite exemplifies several testing best practices:

  • Modular test organization with clear separation of concerns
  • Custom matcher implementation for readable assertions
  • Robust viewport management for consistent testing
  • Proper test isolation and cleanup
  • Comprehensive edge case handling

angular/angularJs

test/e2e/tests/anchor-scroll.spec.js

            
'use strict';

describe('$anchorScroll', function() {
  beforeEach(function() {
    jasmine.addMatchers({
      toBeInViewport: function() {
        return {
          compare: function(id) {
            var result = {
              pass: browser.driver.
                executeScript(_script_isInViewport, id).
                then(function(isInViewport) {
                  result.message = 'Expected #' + id + (isInViewport ? ' not' : '') +
                                   ' to be in viewport';
                  return isInViewport;
                })
            };

            return result;
          }
        };
      },
      toHaveTop: function() {
        return {
          compare: function(id, expectedTop) {
            var result = {
              pass: browser.driver.
                executeScript(_script_getTop, id).
                then(function(actualTop) {
                  // Some browsers may report have +/-1 pixel deviation
                  var passed = Math.abs(expectedTop - actualTop) <= 1;
                  result.message = 'Expected #' + id + '\'s top' + (passed ? ' not' : '') +
                                   ' to be ' + expectedTop + ', but it was ' + actualTop;
                  return passed;
                })
            };

            return result;
          }
        };
      }
    });
  });

  describe('basic functionality', function() {
    beforeEach(function() {
      loadFixture('anchor-scroll');
    });

    it('should scroll to #bottom when clicking #top and vice versa', function() {
      expect('top').toBeInViewport();
      expect('bottom').not.toBeInViewport();

      element(by.id('top')).click();
      expect('top').not.toBeInViewport();
      expect('bottom').toBeInViewport();

      element(by.id('bottom')).click();
      expect('top').toBeInViewport();
      expect('bottom').not.toBeInViewport();
    });
  });

  describe('with `yOffset`', function() {
    var yOffset = 50;
    var buttons = element.all(by.repeater('x in [1, 2, 3, 4, 5]'));
    var anchors = element.all(by.repeater('y in [1, 2, 3, 4, 5]'));

    beforeEach(function() {
      loadFixture('anchor-scroll-y-offset');
    });

    it('should scroll to the correct anchor when clicking each button', function() {
      var lastAnchor = anchors.last();

      // Make sure there is enough room to scroll the last anchor to the top
      lastAnchor.getSize().then(function(size) {
        var tempHeight = size.height - 10;

        execWithTempViewportHeight(tempHeight, function() {
          buttons.each(function(button, idx) {
            // For whatever reason, we need to run the assertions inside a callback :(
            button.click().then(function() {
              var anchorId = 'anchor-' + (idx + 1);

              expect(anchorId).toBeInViewport();
              expect(anchorId).toHaveTop(yOffset);
            });
          });
        });
      });
    });

    it('should automatically scroll when navigating to a URL with a hash', function() {
      var lastAnchor = anchors.last();
      var lastAnchorId = 'anchor-5';

      // Make sure there is enough room to scroll the last anchor to the top
      lastAnchor.getSize().then(function(size) {
        var tempHeight = size.height - 10;

        execWithTempViewportHeight(tempHeight, function() {
          // Test updating `$location.url()` from within the app
          expect(lastAnchorId).not.toBeInViewport();

          browser.setLocation('#' + lastAnchorId);
          expect(lastAnchorId).toBeInViewport();
          expect(lastAnchorId).toHaveTop(yOffset);

          // Test navigating to the URL directly
          scrollToTop();
          expect(lastAnchorId).not.toBeInViewport();

          browser.refresh();
          expect(lastAnchorId).toBeInViewport();
          expect(lastAnchorId).toHaveTop(yOffset);
        });
      });
    });

    it('should not scroll "overzealously"', function() {
      var lastButton = buttons.last();
      var lastAnchor = anchors.last();
      var lastAnchorId = 'anchor-5';

      if (browser.params.browser === 'firefox') return;

      // Make sure there is not enough room to scroll the last anchor to the top
      lastAnchor.getSize().then(function(size) {
        var tempHeight = size.height + (yOffset / 2);

        execWithTempViewportHeight(tempHeight, function() {
          scrollIntoView(lastAnchorId);
          expect(lastAnchorId).toHaveTop(yOffset / 2);

          lastButton.click();
          expect(lastAnchorId).toBeInViewport();
          expect(lastAnchorId).toHaveTop(yOffset);
        });
      });
    });
  });

  // Helpers
  // Those are scripts executed in the browser, stop complaining about
  // `document` not being defined.
  /* eslint-disable no-undef */
  function _script_getTop(id) {
    var elem = document.getElementById(id);
    var rect = elem.getBoundingClientRect();

    return rect.top;
  }

  function _script_isInViewport(id) {
    var elem = document.getElementById(id);
    var rect = elem.getBoundingClientRect();
    var docElem = document.documentElement;

    return (rect.top < docElem.clientHeight) &&
           (rect.bottom > 0) &&
           (rect.left < docElem.clientWidth) &&
           (rect.right > 0);
  }
  /* eslint-enable */

  function execWithTempViewportHeight(tempHeight, fn) {
    setViewportHeight(tempHeight).then(function(oldHeight) {
      fn();
      setViewportHeight(oldHeight);
    });
  }

  function scrollIntoView(id) {
    browser.driver.executeScript('document.getElementById("' + id + '").scrollIntoView()');
  }

  function scrollToTop() {
    browser.driver.executeScript('window.scrollTo(0, 0)');
  }

  function setViewportHeight(newHeight) {
    return browser.driver.
      executeScript('return document.documentElement.clientHeight').
      then(function(oldHeight) {
        var heightDiff = newHeight - oldHeight;
        var win = browser.driver.manage().window();

        return win.getSize().then(function(size) {
          return win.
            setSize(size.width, size.height + heightDiff).
            then(function() { return oldHeight; });
        });
      });
  }
});