Angular Components Test Kit

Talk

Can be downloaded via Slideshare Note - please download in order to be able to utilize the animations included in the deck.

Overview

Component tests allows testing the UI (provided that it is build as components/directives of course) in order to get instant feedback and minimize the use of protractor.

The test kit provides a base driver utility class - TurnerComponentDriver, which allows writing tests using typescript in order to test components in a more readable, maintainable and quicker manner, It provides basic methods to allow rendering a template, accessing underlying elements and ability to maintain drivers' hierarchy, in order to be able to reuse drivers in parent components.

The best practice implemented by TurnerJS in regards to elements' selectors is by using a 'data-hook' attribute in order to mark elements which are accessed by your code. It implements the approach demonstrates in this article, but it is not mandated by the framework, one can use any other data selectors approach.

Installation - Bower

  1. install using bower
    bower install --save turnerjs
  2. Include the following reference in your Karma configuration file
    '<path to your app>/bower_components/turnerjs/dist/test/lib/turnerjs-driver.js'
  3. Optional - if you are using TypeScript (recommended) add reference to the d.ts file in your tsconfig file:
    "bower_components/turnerjs/dist/test/lib/turnerjs-driver.d.ts"

Installation - NPM

  1. install using node package manager
    npm install -S turnerjs
  2. Include the following reference in your Karma configuration file
    'node_modules/turnerjs/module/generated/turnerjs-driver.js' or
    require(turnerjs) (when window is supported)
  3. Optional - if you are using TypeScript (recommended) add reference to the d.ts file in your tsconfig file:
    "../node_modules/turnerjs/module/generated/turnerjs-driver.d.ts"

Usage

   class ExampleComponentDriver extends TurnerComponentDriver {

        constructor() {
          super();
        }

        render(param1, param2) {
          //renders the template and adds the input parameters as scope params (second argument to renderFromTemplate method)
          //passing a selector is needed when the element containing the directive is not the first element in the template or when there is more than one driver
          this.renderFromTemplate('<example-component another-attribute="param2"><div data-hook="inner-element">{{param1}}</div></example-component>', {param1, param2}, 'example-component');
        }

        isInnerElementValid(): boolean {
           return this.findByDataHook('inner-element').text() === 'valid';
        }
      }
    let driver: ExampleComponentDriver;
    beforeEach(() => {
       driver = new ExampleComponentDriver();
       driver.render('valid', 'yada yada');
       driver.connectToBody();
    });

    afterEach(() => {
        driver.disconnectFromBody();
    });

    it('should contain a valid element value', () => {
        expect(driver.isInnerElementValid()).toBeTruthy();
    });

Global Methods added by the test kit

Param Type Arguments Details
byDataHook global method dataHook: string Create a data-hook selector it is useful for methods that uses selectors such as TurnerComponentDriver.defineChild

Base Driver Methods/ Members

Param Type Arguments Details
constructor Constructor N/A Creates the driver
renderFromTemplate protected method template: string, args?: Object, selector?: string Allows rendering the component/directive, the args is a key value pairs object that will be added to the scope of the element, initializes the root of the driver according to the selector
findByDataHook protected method dataHook: string a utility method that should be used by drivers that inherits from the base driver in order to select an element (first if there are several) by data-hook attribute. It will throws an error if called before renderFromTemplate was called
findAllByDataHook protected method dataHook: string similar to findByDataHook but allows selecting several elements with the same data-hook
defineChild protected method childDriver: Instance of T such that T extends TurnerComponentDriver, selector: string representing a CSS selector (preferably called with byDataHook(dataHook)) Declare a child driver of the current driver, allows components hierarchy, which is also returned by the method. This method should be called before renderFromTemplate was called
defineChildren protected method factory: function that returns an instance of T such that T extends TurnerComponentDriver, selector: string representing a CSS selector (which is expected to return more than one result) returns an array of child drivers (instances of T), it is useful when there is more than one child driver for parent driver (e.g. ng-repeat), the returned array will change when there is a change in the number of elements in the dom. This method should be called before renderFromTemplate was called
applyChanges public method N/A invokes $rootScope.$digest(), mainly aimed to 'hide' AngularJS related implementation
connectToBody public method N/A Connects the template to the karma's browser body - allows height/width related tests. disconnectFromBody has to be called at the end of the test. It will thorw an error if called before renderFromTemplate was called
disconnectFromBody public method N/A Clears the the body of the karma's browser, used in order to reset the browser to the original state prior to starting the next test
afterRender protected method N/A You can override this method if you need extra setup after render
element ng.IAugmentedJQuery N/A Reference to the element that represents the root of the driver (by selector if provided or template root)
scope ng.IScope N/A Reference to the scope of element member
isRendered boolean N/A defines whether the driver is rendered - whether its template was rendered and its scope is valid (defined and part of the dom)
appendedToBody boolean N/A defines whether the driver's element is appended to body (e.g. a driver for bootstrap tooltip)
$rootScope ng.IRootScopeService N/A Reference to the $rootScope service (removes the need to inject it in tests)

Nested drivers

In order to allow reuse of drivers, the base driver supports initializing any child element (member) that extends TurnerComponentDriver For example, assuming 3 components are defined:

   angular.module('myModule', []);
   class ItemComponentCtrl {
     public item: {value: number};

     isValid():  boolean {
       return this.item.value > 1;
     }
   }

   angular
     .module('myModule')
     .component('itemComponent', {
       template: `<div data-hook="item-element" ng-class="{'valid': $ctrl.isValid()}"/>`,
       controller: ItemComponentCtrl,
       bindings: {
         item: '='
       }
     });

   class IndividualComponentCtrl {
     public item: {value: number};

     getText():  string {
       return this.item.value > 1 ? 'valid' : 'invalid';
     }
   }

   angular
     .module('myModule')
     .component('individualComponent', {
       template: `<div data-hook="inner-element">{{$ctrl.getText()}}</div>`,
       controller: IndividualComponentCtrl,
       bindings: {
         item: '='
       }
     });

   class ParentComponentCtrl {
     public items: {value: number}[];

     constructor() {
       this.items = [];
       for (let i = 0; i < 5; i++) {
         //push either 1 or 2
         this.items.push({
           value: Math.floor((Math.random() * 2) + 1)
         });
       }
     }
   }

   angular
     .module('myModule')
     .component('parentComponent', {
       template: `<div>
                    <individual-component item="$ctrl.items[0]"/>
                    <item-component ng-repeat="item in $ctrl.items" item="item"/>
                  </div>`,
       controller: ParentComponentCtrl
     });

3 Drivers that corresponds to each are defined:
(When there is a list of child drivers - e.g. when using ng-repeat, defineChildren method should be used in order to declare an array of child drivers)

class IndividualComponentDriver extends TurnerComponentDriver {

  constructor() {
    super();
  }

  render(item) {
    this.renderFromTemplate('<individual-component item="item"/>', {item});
  }

  isValid(): boolean {
    return this.findByDataHook('inner-element').text() === 'valid';
  }
}

class ItemComponentDriver extends TurnerComponentDriver {

  constructor() {
    super();
  }

  render(item) {
    this.renderFromTemplate('<item-component item="item"/>', {item}, 'item-component');
  }

  isValid(): boolean {
    return this.findByDataHook('item-element').hasClass('valid');
  }
}

class ParentComponentDriver extends TurnerComponentDriver {
  public innerComponent: IndividualComponentDriver;
  public itemComponents: ItemComponentDriver[];

  constructor() {
    super();
    this.innerComponent = this.defineChild(new IndividualComponentDriver(), 'individual-component');
    this.itemComponents = this.defineChildren(() => new ItemComponentDriver(), 'item-component');
  }

  render() {
    this.renderFromTemplate(`<parent-component>`);
  }

  isIndividualValid(): boolean {
    return this.innerComponent.isValid();
  }

  isItemsValid(): boolean {
    let result = true;
    this.itemComponents.forEach(itemDriver => {
      result = result && itemDriver.isValid();
    });
    return result;
  }
}

TurnerComponentDriver will initialize the member's scope & element automatically as soon as the renderFromTemplate method is invoked. The above drivers will allow testing each component separately and also testing the parent component that wraps the two:

describe('Usage Examples when there are repeatable drivers', () => {
    let parentComponentDriver: ParentComponentDriver;
    beforeEach(() => {
      module('myModule');
      parentComponentDriver = new ParentComponentDriver();
    });

    it('should be valid when the random values are above 1', () => {
      spyOn(Math, 'random').and.returnValue(0.9);
      parentComponentDriver.render();
      expect(parentComponentDriver.isIndividualValid()).toBe(true);
      expect(parentComponentDriver.isItemsValid()).toBe(true);
    });
  });

Non TypeScript usage

Though the recommendation is to use TypeScript with turnerjs, if you are not using it, you can create the driver using prototypical inheritance. There are various ways to implement it, but the test kits includes a test spec for ES5 usage (see app/test/spec/components/es5-name-formatter.js) The below provides the basic implementation of such driver:

/* globals TurnerComponentDriver */ //for jshint
function ES5Driver() {
  TurnerComponentDriver.call(arguments);
}

ES5Driver.prototype = new TurnerComponentDriver();
ES5Driver.constructor = ES5Driver;

ES5Driver.prototype.render = function () {
  this.renderFromTemplate('<es5-component-template></es5-component-template>');  
};
//other driver methods

describe('your tests are here', function () {
    var es5driver;

    beforeEach(function () {
        angular.mock.module('turnerjsAppInternal');
        driver = new ES5Driver();
    });

    it('...', function () {
        driver.render();
        //test e
    });
});

Contribution

Via pull requests,

After cloning the repository please run npm install and bower install in order to fetch dependencies

Running/Building the project is done by using grunt:
grunt serve:clean - will start the server, there is no real UI for it, but it will run the unit tests on each save grunt build - build the project to make sure that changes are valid and meeting all code style definitions

Credits

Alon Yehezkel
Shahar Talmi
Boris Litvinski
Amit Shvil

License

The MIT License.

See LICENSE