AngularJS Corner – How to unit test forms

by
Tags: ,

A lot of people are still using AngularJS. It’s hard to port your entire application to a new platform and even language. This week we had a student who asked a very good question: how do you unit test forms in AngularJS?

Well, this article got me started. I wanted to put it in context with modern ES2015 AngularJS apps and give it a proper tutorial.

Testing Forms in Angular

Forms in AngularJS are a template-driven beast. That means you have to initialize a controller in a test, load the template for the form, and then compile the template using the scope the controller creates.

I’ll present an example Jasmine test here. I am cheating a tad here, using a WebPack ES2015 build so I’m using arrow functions and require() to load the template (more about that below). The test is also using Jasmine Spies to fake out a call from a service, and the app is comprised of several modules, two of which I’m loading here (the controller’s module and a service module that provides the service).

Assume our form is called form (I’m not so inventive this morning), and that it contains three form fields: description, priority, and dueDate.

Here is a small fragment of the form itself:

<h3>New task..</h3>
<form name='form'>
  <input type='text' required
         name="description"
         ng-model='vm.task.description'>
  <input type='number'
         name='priority'
         required min='1' max='5'
         ng-model='vm.task.priority'>
  <input type="text"
         name="dueDate"
         uib-datepicker-popup="MM-dd-yyyy"
         ng-model="vm.task.dueDate"
         required="true" >
  <div class='form-group'>
    <button ng-click='vm.addTask(vm.task)'
      id='createTask'
      ng-disabled='form.$invalid'>
      Create Task!</button>
  </div>
</form>

Setting up the test

First, we set up the test. We start by loading Angular from node_modules, and we define the Jasmine describe block. In the first beforeEach we initialize our modules.

var angular = require('angular');
describe('New Task Form View', () => {
  var $scope, $rootScope, $compile, $controller;
  var httpTaskService, controller, formObject, modelObject;
  // tell the angular testing engine to load these modules into the Jasmine context
  beforeEach(
    angular.mock.module('taskManagerApp.controllers',
                        'taskManagerApp.services'));

Injecting our services

Our spec will now inject services it needs for the testing to occur:

  // now inject our key services
  beforeEach(angular.mock.inject(($injector) => {
    $controller = $injector.get('$controller');
    $compile = $injector.get('$compile');
    $rootScope = $injector.get('$rootScope');
    httpTaskService = $injector.get('httpTaskService');
  }));

In the code above, we need the $controller service to create an instance of a controller, the $compile service to interpolate a template (our view with the form in it) against the controller and a scope, the $rootScope to create a scope for the controller to use, and a reference to our httpTaskService which we’ve built to get our form’s task.

Mocking the service method

In typical samples out there, we see the Angular way of stubbing a service using the properties in $controller. Actually you have more power using the Jasmine spyOn function. Java programmers will likely prefer it as it functions very similar to EasyMock or Mockito.

  // next, provide the spy to mock our service
  beforeEach(() => {
    spyOn(httpTaskService, 'createTask')
      .and.returnValue({
        description: '',
        priority: null,
        complete: false,
        dueDate: new Date('1/15/2019')
      });
  });

Once Jasmine spies on a method, it replaces the original. The strategy we’re using is to stub it and return a fake value. This will do for our test to isolate it. Also, we’d prefer to inject the service and mock the value over just stubbing it completely, so we know that the Angular dependency injector is wired properly to inject the service itself.

Invoke our controller and apply it to a template

Now for the cooler part: getting a unit test to evaluate HTML and run a controller without firing up the application itself. Comments inline here…

  // establish our scope and load the template and controller
  beforeEach(() => {
    // We should construct a child scope to act like a typical ng-controller
    $scope = $rootScope.$new();
    // load the template as a string
    const template = require('../../views/new-task-form.html');
    // load the controller (actually run it) and pass it the scope we created
    controller = $controller('NewTaskFormController as vm', {
      $scope: $scope
    });
    // interpolate the template, applying it to the scope to make it "live"
    $compile(template)($scope);
  });

At this point, we now have a fully wired component and scope, including the alias to the view model (vm) for our form to work.

Helping our testers

For the tester to have an easier time, we’ll provide some key variables.

  // now, establish our test variables
  beforeEach(() => {
    // our template has a named form, 'form' (name="form")
    formObject = $scope.form;
    // The form is bound to the model we're editing
    modelObject = controller.task;
  });

Now let’s test…

Initial state of the form

Our form starts out with two invalid fields (they are empty and the form uses a required directive for each field) and one with a default value (the date field). Let’s make sure this is the case. It is crucial after each change to the model or contents in the field to run $rootScope.$digest() to trigger AngularJS’s digest cycle. This applies any changes and validates the form.

  it('should have an invalid form by default', () => {
    // trigger a digest cycle to validate the form
    $rootScope.$digest();
    // now check validity
    expect(formObject.$invalid).toBe(true);
    expect(formObject.description.$invalid).toBe(true);
    expect(formObject.priority.$invalid).toBe(true);
    expect(formObject.dueDate.$valid).toBe(true);
    expect(modelObject).toBeDefined();
    expect(formObject.dueDate.$valid).toBe(true);
  });

Testing the happy path, a valid form

Now we can test the valid form. We modify the model (don’t even bother figuring out how to type into the form, just modify the model applied to each form field (with ng-model) and apply the changes. This beats the heck out of Protractor:

  it('should be a valid form once data is provided', () => {
    modelObject.description = 'But it is cool';
    modelObject.priority = 3;
    $rootScope.$digest();
    expect(formObject.$valid).toBe(true);
  });

Trigger various invalid states

Our last test – check the min/max validity of the priority field.

  it('should not allow a non-valid priority', () => {
    modelObject.description = 'A valid description';
    modelObject.priority = -1100;
    $rootScope.$digest();
    expect(formObject.$valid).toBe(false);
    expect(formObject.priority.$valid).toBe(false);
    modelObject.priority = 9;
    $rootScope.$digest();
    expect(formObject.$valid).toBe(false);
    expect(formObject.priority.$valid).toBe(false);
  });
}

Wrap-up

I hope this helps you think of ways you can test form objects in Angular without resorting to expensive, slow, and brittle web tests. It’s not a panacea, but remember, unit and system tests in Jasmine run light-years faster than web tests in Selenium/Protractor and don’t require complex page objects and dealing with lots of ugly promise code.

Hire us for your AngularJS Training

If you found this article useful, and you’re struggling with AngularJS, have no fear. Chariot can teach your organization how to program in AngularJS on-site. See our training course list to view our AngularJS, Angular and React courses. We also provide mentoring services for one-on-one or small-team help.