Saturday, May 4, 2013

Lesson 4: Angularjs Tutorial: Testing and Project Structure

In Lesson 3 we finished our device management CRUD in angularjs. We used template bindings, controllers, modules and forms to come up with an effective CRUD model. But we have not written any tests yet. Angularjs comes with a pretty solid foundation to support Test Driven Development (TDD) and allows us to do unit testing as well as end to end testing. In this section, lets take a step back, learn about how to write effective tests for angularjs. We should also organize our project structure which will enable us to write complex applications in a modular way.

Project Structure and using the Angular Seed


First lets tackle the subject of Project Structure for angularjs applications. I think the easy thing would be to use one of the project seeds made available by the core project team. This is available at https://github.com/angular/angular-seed.

1. Clone angular-seed into a separate directory and not your working directory
  • cd ~/angular
  • git clone https://github.com/angular/angular-seed
2. Create a new branch in our existing git repo so that we can do this work independently and later merge into our main branch
  • cd ~/angular/cotd
  • git branch seed_refactor - create a branch called seed_refactor to do our work locally
  • git checkout seed_refactor - change to point to the newly created branch
  • git branch - this shows which branch we are on
3. Lets create and slowly make the seed directory structure into our project. For new projects its better to start with the seed straight away or better yet use Yeoman (we'll get into that later)
  • cd ~/angular/cotd
  • mkdir config  -- create the config directory. Mainly holds karma test configs


  • cp ~/angular/angular-seed/config/* ./config/.
  • ls config
  • mkdir logs
  • mkdir test
  • cp -fr ~/angular/angular-seed/test/ ./test/.
  • cp -fr ~/angular/angular-seed/scripts .
  • cp -fr ~/angular/angular-seed/app .

  • 4. Modify the seed app (manually modifying the app is good because it will allow us to know the structure intimately, before proceeding to add more items)
    • modify index.html, instead of copying it. main.html is fairly simple in our case so should be easy to modify
      • change app name ng-app = 'cotd'
      • change title
      • add bootstrap css  
      • remove anything that we dont need
    • We will replace cotd.js with various components within the seed structure
      • routes should go in app.js
      • refactor controllers into the controller structure provided by the seed package
      • refactor the service into the services file
    Version Tag 1.3.2 should contain the refactored code which is working. To get this into your directory do the following:

    • cd ~/angular/cotd
    • git checkout 1.3.2
    We are now done refactoring our original application folder to using the angular-seed project structure. This offers a lot of advantages, all of the testacular/Karma configurations are already setup for us, it also has scrips that allow us to run the web server and test runners. Lots of good in angular-seed. 

    Testing

    Angularjs has been designed to allow us to effectively do TDD. It allows us to do both unit testing as well as end-to-end testing. Currently our project does not have any tests... we will rectify that shortly. Here is our plan:
    1. Write unit tests to test the functionality of the Device Service
    2. Write unit tests for the controllers
    3. Write end-to-end unit tests
    Angular seed makes it easy for us to write and execute tests. We will be using the Karma (a.k.a testacular) testing integration provided with the seed to help us quickly write tests & let the seed configuration & scripts take care of executing them. 

    To run the unit tests type at the prompt: ./scripts/test.sh

    This opens a browser window & all the files will be watched. Any changes you make will run all the tests. This significantly improves our workflow and allows to achieve TDD. The debug console should allow you to see the test results. 

    Testing Services

    Lets start by writing a simple test. Our first test is to ensure that our Device Service should exist. We will be using jasmine to describe our tests & write expectations of behavior. Our simple test is listed below: The test/unit/serviceSpec.js file already sets up the module in beforeEach so its available later. 

    describe('service', function() {
      beforeEach(module('cotd.services'));

      describe('Devices', function() {
        it('should exist', inject(function(Devices){
            expect(Devices).not.toBe(null);
        })); // it

      }); // Devices

    }); // service

    You can test this by running ~/angular/cotd/scripts/test.sh. Keep the browser window open. Any additional changes you make to the app or tests will be watched and all tests immediately run. This workflow allows us to do continuous TDD. 

    We can start to add additional tests for the service. Lets add a test to see if our query functionality is working as expected. Inject the Device service & expect the data lenght to be equal to 7:

        it('should contain devices', inject(function(Devices) {
          expect(Devices.query().length).toEqual(7);
        }));

    Similarly, we can add unit tests for both Add & Update functionality within the services.

    Add unit-test:

        it('should add a device correctly', inject(function(Devices) {
            var item = {id:7, name: "iphone", assetTag:"a23456", owner:"dev", desc:"iOS4.2"};
            Devices.add(item)
          expect(Devices.query().length).toEqual(8);
        }));

    Update unit-test:

        it('should update a device correctly', inject(function(Devices) {
            // modified device name test
            var item = {id:0, name: "iphone-test", assetTag:"a23456", owner:"dev", desc:"iOS4.2"};
            Devices.update(item)
            expect(Devices.query().length).toEqual(7);
            //get first item
            expect(Devices.query()[0].name).toEqual("iphone-test");
        }));

    As you can see, testing services is fairly straightforward since we have the data in the service stored statically. Once we refactor our code to retrieve data from the backend, we need to refactor these test cases to handle those cases. 

    Testing Controllers

    Angularjs makes testing controllers pretty straightforward too, once you understand certain basic concepts. Define modules for both services & controllers so that they are available for this spec:


    describe('controllers', function(){
      beforeEach(function(){
        module('cotd.controllers');
        module('cotd.services');
      });

    The first unit test case is to make sure the controller scope has the right number of devices. Remember, the scope is bound in the template, so verifying that it is accurate is a good idea:


     it('listDeviceController should have 6 devices in the model',
        inject(function($rootScope, $controller, Devices) {
            var scope = $rootScope.$new();
            var ctrl = $controller('deviceListController', {$scope: scope}, Devices);
            // check the number of devices
            expect(scope.devices.length).toEqual(7);
      }));

    Unit test for Adding a device & verifying that the scope accounts for the add:

      it('addDeviceController should have 8 devices in the model after add',
        inject(function($rootScope, $controller, $location, Devices) {
            var scope = $rootScope.$new();
            var ctrl = $controller('addDeviceController', {$scope: scope}, $location, Devices);
            var item = {id:7, name: "iphone", assetTag:"a23456", owner:"dev", desc:"iOS4.2"};

            scope.add(item);
            // make sure the flag is true
            expect(scope.add).toBeTruthy();
            // check actual add effected the devices list
            expect(Devices.query().length).toEqual(8);
      }));

    Lets test the edit functionality too:

      it('editDeviceController should successfully edit devices in the model',
        inject(function($rootScope, $controller, $location, $routeParams, Devices) {
            var scope = $rootScope.$new();
            var ctrl = $controller('editDeviceController', {$scope: scope}, $routeParams, $location, Devices);
            var item = {id:0, name: "iphone", assetTag:"a23456", owner:"qa", desc:"iOS4.3"};
            // testing for update flag
            expect(scope.add).toBeFalsy();
            // confirm original lenght
            expect(Devices.query().length).toEqual(7);
            // confirm description
            expect(Devices.query()[0].desc).toEqual('iOS4.2');
            // update
            scope.update(item);
            // validate update
            expect(Devices.query()[0].desc).toEqual('iOS4.3');
            expect(Devices.query()[0].owner).toEqual('qa');
      }));

    Writing End To End Tests

    Writing and executing End to End (e2e) tests is a powerful mechanism that angularjs provides which is extremely valuable for large-scale applications. Angular consructs uses jquery and matchers to help us write pretty quick e2e tests. The Karma runner also makes it easy to run the tests in multiple browsers. 

    E2E tests starts the browser and uses that as a client which has an added advantage from other unit testing frameworks. 

    describe('my app', function() {

      beforeEach(function() {
        browser().navigateTo('../../app/index.html');
      });

      it('should automatically redirect to / when location hash/fragment is empty', function() {
        expect(browser().location().url()).toBe("/");
      });


      describe('DisplayList', function() {

        beforeEach(function() {
          browser().navigateTo('#/');
        });

    This E2E test allows navigates to the displayDevices page and can use jquery DOM matching to test for specific conditions. For example, below, it tests for item.owner column in the table. 

        it('should render DisplayDevices when user navigates to /#', function() {
          expect(element('[ng-view] a').text()).
            toMatch(/Would you like to add a new device/);
          
          expect(element('[ng-view] th').text()).
            toMatch(/Name/);

          // counting number of rows
          expect(repeater('tbody tr').count()).toBe(7);

          // matching the resulting elements in a column
          expect(repeater('tbody tr').column("item.owner"))
            .toEqual(["dev","dev","qa","dev","dev","qa","dev"]);
        });

        // clicking one item in the table using :eq(0)
        it("clicking remove link should remove device", function(){
          element('tbody tr:eq(0) .icon-trash').click();
          expect(repeater('tbody tr').count()).toBe(6);
        });

        // clicking a link should take you to add device URL, selection by id
        it("clicking add Device link should take you to right screen", function(){
          element('#addlink').click();
          expect(browser().location().url()).toBe('/add');
        });    

      });

    Here is the snippet to do E2E test for adding a new device:

        // test for Add & subsequent expectation that the devices list has the added value
        it('should add a Device when user enters values and submits them', function() {
          input('device.name').enter('TestName');
          input('device.assetTag').enter('TestAssetTag');
          input('device.owner').enter('TestOwner');
          input('device.desc').enter('TestDescription');
          element('#addDevice').click();

          // counting number of rows
          expect(repeater('tbody tr').count()).toBe(8);

          // matching the resulting elements in a column
          expect(repeater('tbody tr').column("item.owner"))
            .toEqual(["dev","dev","qa","dev","dev","qa","dev", "TestOwner"]);
        }); 


    Here is the test snippet for modifying & testing the modified element. We are navigating to the first record "#/edit/0' using the browser control. Then we use a very similar pattern to enter values and test the result. 

      describe('updateDeviceView', function() {

        beforeEach(function() {
          browser().navigateTo('#/edit/0');
        });

        // test for update & subsequent expectation that the devices list has the updated value
        it('should update a Device when user enters values and submits them', function() {
          input('device.name').enter('TestName');
          input('device.assetTag').enter('TestAssetTag');
          input('device.owner').enter('TestOwner');
          input('device.desc').enter('TestDescription');
          element('#updateDevice').click();

          // counting number of rows
          expect(repeater('tbody tr').count()).toBe(7);

          // matching the resulting elements in a column
          expect(repeater('tbody tr').column("item.owner"))
            .toEqual(["TestOwner","dev","qa","dev","dev","qa","dev"]);
        }); 

      });

    With this we complete our testing. We refactored our code base using angular-seed which helps us tremendously to organize our code & provides a full functional workflow environment to do TDD. To download the latest code, you can checkout the following version from git. 

    git checkout v1.4

    Next up is to retrieve the data from an external REST based backend. We will build a Node Express Mongo backend which integrates with our angularjs frontend. 








    No comments: