Testing ngLazy: unit tests with karma & jasmine

Excited as I am to have implemented my lazy load and infinite scroll modular AngularJS library, ngLazy, I considered it pretty fragile because it was lacking a test suite. And yes, I'm admitting that this was not developed in the style of TDD. I would like to make that a priority for the next project!

My concerns had little to do with the library's ability to handle edge cases, and much more to do with a strong desire to open up my code to the open source community. I didn't feel it was responsible to do this -- to solicit other people for their time and thoughtfulness -- without providing some automated indicator that the library has not been broken by a change that is made. So, I got down to work and wrote some tests.

The recommended engine for unit testing an AngularJS app is karma, and by default, it uses the Jasmine testing framework. npm install karma, followed by karma init got me moving pretty quickly, but there were configuration kinks that took a minute to iron out.

Knowing what questions to ask

A big question was raised before I could begin, though. How was I going to adequately demonstrate and test this library, of which there was but only a few features packed into a module, without a standalone purpose? My solution was to create a demonstration app for the features of the library, and in doing so, integrate my test suite with the demo. The result - http://ng-Lazy.com. My expectations are that this app will be integrated in the development cycle for further iterations on the library, and that the tests will be run against it.

Unit tests should be as decoupled from all pieces of an application as possible, in order to only test the operation of the single feature being tested. So this begged the question, what are the elements of this feature whose functionality needs to be varified in order to ensure nothing has been broken? Understanding which concepts to test was as challenging as figuring out how to test them. This would probably have been easier if the library had been developed in a TDD style!

Regardless, I combed through the pieces of ngLazy that make it tick and came away with some key assertions:

  • the lazyLoader Factory should have the proper methods

  • it should have a div with id 'bottom'

  • when repeated items are rendered, the bottom div should follow the last item

  • it should have a scope with keys for every lazy-load attribute

Now that I had some guidelines to shoot for, I needed to mock up their execution.

Some vital parts of mocking the Angular environment in which the piece you are testing operates:

  • require the module being tested before each test runs
  • mock a new $scope from the rootScope constructor, and
  • inject depedencies via the $injector provider:
  beforeEach(module('ngLazy'));
  beforeEach(inject(['$rootScope','$controller','$injector',function($rootScope, $controller, $injector){
    lazyLoader = $injector.get('lazyLoader');
    $scope = $rootScope.$new();
  }]));
  • with lazyLoader being my module's factory, now I can check that some good-intentioned contributor doesn't break its backwards compatibility:
  it('should have a lazyLoader Factory', function(){
    expect(lazyLoader).not.toBe(undefined);
  });

  it('the lazyLoader Factory should have the proper methods', function(){
    expect(lazyLoader.configure).not.toBe(undefined);
    expect(lazyLoader.getData).not.toBe(undefined);
    expect(lazyLoader.load).not.toBe(undefined);
  });

Testing a directive

In order to test the directive that this module provides, we need to mock an angular element that invokes it, and give it some controller data to validate the features are functioning.

NOTE: if your directive has isolate scope whose properties you will be testing, use the isolateScope() method on your angular element, AFTER you have invoked $compile on it with a scope.

      describe('Directive', function(){
        beforeEach(module('ngLazy'));

        var element, $scope, list, bottom, elementScope;

        beforeEach(inject(['$rootScope','$compile', function($rootScope, $compile){
          $scope = $rootScope.$new();
          $scope.data = {};
          $scope.data.list = [
            "item1",
            "item2",
            "item3",
            "item4",
            "item5"
          ];
          element = angular.element(
          '<lazy-load' +
            ' lazy-data="data"' +
            ' lazy-data-service="dataService"' +
            ' lazy-fetch-method="getList"' +
            ' lazy-range=" {{ range }}"' +
            ' lazy-data-collection-key="list"' +
            ' lazy-data-keys="[\'list\']"' +
            ' lazy-start-delay="{{ startDelay }}"' +
            ' lazy-append-delay="{{ appendDelay }}"' +
            ' lazy-spinner-color="{{ spinnerColor }}">' +
          '<div ng-repeat="item in data.list">' +
            '<h4>{{ item }}</h4>' +
          '</div>' +
          '</lazy-load>');

          $scope.$apply();
          $compile(element)($scope);

          elementScope = element.isolateScope();
        }]));

        it('should not break ng-repeat', function(){
          $scope.$digest();
          list = element.find('h4');
          expect(list.length).toBe(5);
        })

        it('should have a div with id=\'bottom\'', function(){
          bottom = element.find('div')[3];
          expect(bottom.id).toBe('lazy-bottom');
        })

        it('when repeated items are rendered, the bottom div should follow the last item', function(){
          $scope.$digest();
          bottom = element.find('div')[8];
          expect(bottom.id).toBe('lazy-bottom');
        })

        it('should have a scope with keys for every lazy-load attribute', function(){
          expect(elementScope.lazyData).not.toBe(undefined);
          expect(elementScope.lazyDataCollectionKey).not.toBe(undefined);
          expect(elementScope.lazyDataKeys).not.toBe(undefined);
          expect(elementScope.lazyFetchMethod).not.toBe(undefined);
          expect(elementScope.lazyRange).not.toBe(undefined);
          expect(elementScope.lazySpinnerColor).not.toBe(undefined);
          expect(elementScope.lazyAppendDelay).not.toBe(undefined);
          expect(elementScope.lazyStartDelay).not.toBe(undefined);
          expect(elementScope.lazyDataService).toBe('dataService');
        });

      })

    })

I swear, I wrote these tests and now I sleep much better a night. Next up, I'll try my hand at some notes about end-to-end testing ngLazy.