So far in this series of AngularJS-related posts, we have looked at some utility factories for tracking web requests and preventing navigation requests, whether in-app or not, and how to write tests for those factories using Jasmine. In this post, we will look at how I generally test directives and how that impacts their structure.
Directives
Directives are the things we write in AngularJS that allow us to extend and modify the HTML language. We provide a template, some code-behind, and data binding, and AngularJS performs its magic to create a new behavior or control that is inserted via tag, attribute or CSS class. However, directives can at first seem difficult to test.
Let's look at a convolutedĀ example.
sa.directive('saEditableField', function () {
return {
restrict: 'E',
require: 'ngModel',
template: '<label>{{fieldName}}</label><input type="text" ng-model="fieldValue" /><button ng-click="showField()">Alert!</button>',
scope: {
fieldName: '@saFieldName',
fieldValue: '=ngModel'
},
controller: function($scope) {
$scope.showField = function() {
window.alert("Your " + $scope.fieldName + " is " + $scope.fieldValue);
};
}
};
});
In this example we have a very simple directive. It specifiesĀ a template, some scope, and a controller where the business logic lives. It is used like this:
<sa-editable-field sa-field-name="First Name" ng-model="fieldValue"></sa-editable-field>
As it stands, there are a one or twoĀ things thatĀ either make this difficult to test or (I feel) could be clearer. Let's refactor this directive with testing in mind.
Tests
When I have a working directive that I want to test, I tend to start writing tests. I realise this seems obvious, but it is worth noting as I do not tend to do much refactoring before writing tests and I don't tend to write tests before I have something to test. I find it much more useful to get a concept working and then think about what tests should be there and how I can implement those tests. When I find friction in authoring those tests, I identify my refactoring priorities.
Considering the directive presented above, what tests do we need? Here is the list of things that I thinkĀ should be tested:
- That the directive exists
- That the directive compiles
- That the alert shows when the button is clicked
- That the alert mentions the field name
- That the alert mentions the field value
Testing that the directive exists is easy (note that we have to add DirectiveĀ to the end for the directive to be injected).
describe 'exists', ->
When => inject (saEditableFieldDirective) =>
@saEditableField = saEditableFieldDirective
Then => expect(@saEditableField).toBeDefined()
Checking it compiles is a little more convoluted, but not difficult.
describe 'compiles', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
@scope.field = 'test'
@htmlFixture = angular.element('<sa-editable-field sa-field-name="Test" ng-model="field"></sa-editable-field>');
When => inject ($compile) =>
$compile(@htmlFixture)(@scope)
@scope.$apply()
Then => @htmlFixture.find('label').length == 1
And => @htmlFixture.find('label').text() == 'Test'
And => @htmlFixture.find('input').length == 1
And => @htmlFixture.find('button').length == 1
The linking function that $compileĀ returns is called becauseĀ Ā $compileĀ can succeed even if our fixture has a typo.
Using knowledge of our directive template, we can verify that the linking worked without going so far as to validate every aspect of the magic Angular does for us. In fact, since this is really intended to testĀ the code that gets calledĀ inside the directive whenĀ it is linked to a scope,Ā the template for the directive could be as simple as <div></div>Ā (we will take a closer look in a future post at how we can make the directive template replaceable).
Testing the Alert
Testing that the directive exists, compiles and links is relatively straightforward when compared with testing that the alert is shown and shows the right thing. In order to test the alert, we need to be able to invoke showField, the method that shows it. We also need to checkĀ that showFieldĀ actuallyĀ shows the alert as we would like.
To invoke showField, we might try using the scope that is passed to the link method returned from $compile. Since the method is added to the scope in the controller, this should work, right? Of course not; this directive has an isolate scope and as such the method has been added to its own copy of the passed scope. We canĀ get to thatĀ isolate scope from the compiled element using the isolateScope()Ā method;
describe '#showField', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
@scope.field = 'test'
@htmlFixture = angular.element('<sa-editable-field sa-field-name="Test" ng-model="field"></sa-editable-field>');
When => inject ($compile) =>
$compile(@htmlFixture)(@scope)
@scope.$apply()
Then => expect(@htmlFixture.isolateScope().showFi
…but I feel like such a test involves too much setup ceremony just to get to the test and it relies on too many things outside of just the bit we want to validate. Instead, what if we couldĀ get at the controller independently of the directive?
sa.directive('saEditableField', function () {
return {
restrict: 'E',
require: 'ngModel',
template: '<label>{{fieldName}}</label><input type="text" ng-model="fieldValue" /><button ng-click="showField()">Alert!</button>',
scope: {
fieldName: '@saFieldName',
fieldValue: '=ngModel'
},
controller: 'saFieldController'
};
});
sa.controller('saFieldController', ['$scope', function($scope) {
$scope.showField = function() {
window.alert("Your " + $scope.fieldName + " is " + $scope.fieldValue);
};
}]);
Now our controller is completely separate from the directive, which refers to the controller by name. This means we can write what I feel are clearer directive tests. Using a fake for the controller, we isolate the directiveĀ from the real thing and any side-effects it may have;
describe 'saEditableField', ->
Given -> module ($provide) ->
fakeFieldController = jasmine.createSpyObj 'saFieldController', ['showField']
$provide.value 'saFieldController', fakeFieldController;return
describe 'exists', ->
When => inject (saEditableFieldDirective) =>
@saEditableField = saEditableFieldDirective
Then => expect(@saEditableField).toBeDefined()
describe 'compiles', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
@scope.field = 'test'
@htmlFixture = angular.element('<sa-editable-field sa-field-name="Test" ng-model="field"></sa-editable-field>');
When => inject ($compile) =>
$compile(@htmlFixture)(@scope)
@scope.$apply()
Then => @htmlFixture.find('label').length == 1
And => @htmlFixture.find('label').text() == 'Test'
And => @htmlFixture.find('input').length == 1
And => @htmlFixture.find('button').length == 1
…and now the controller can have its own tests too;
describe 'saFieldController', ->
describe 'exists', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
When => inject ($controller) =>
@saFieldController = $controller 'saFieldController', { $scope: @scope }
Then => expect(@saFieldController).toBeDefined()
describe '#showField', ->
describe 'exists', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
When => inject ($controller) =>
@saFieldController = $controller 'saFieldController', { $scope: @scope }
Then => expect(@scope.showField).toEqual jasmine.any(Function)
$window
However, we are not quite finished. How do we know that the alert is shown when our showFieldĀ method is called?Ā We could spy on the global windowĀ object, but that's not really veryĀ Angular-y and our alert will get shown during testing (goodnessĀ knows what other side-effects we might face by using the global windowĀ object). What we need is an injectable version of windowĀ that we can replace with a fake during our tests.Ā Not unsurprisingly, AngularJS has us covered with theĀ $windowĀ service.
sa.controller('saFieldController', ['$scope', '$window', function($scope, $window) {
$scope.showField = function() {
$window.alert("Your " + $scope.fieldName + " is " + $scope.fieldValue);
};
}]);
Now with our controller rewritten to use $window, we can tidy up and complete itsĀ test cases.
describe 'saFieldController', ->
Given -> module ($provide) ->
$provide.value '$window', jasmine.createSpyObj('$window', ['alert']);return
describe 'exists', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
When => inject ($controller) =>
@saFieldController = $controller 'saFieldController', { $scope: @scope }
Then => expect(@saFieldController).toBeDefined()
describe '#showField', ->
describe 'exists', ->
Given => inject ($rootScope) =>
@scope = $rootScope.$new()
When => inject ($controller) =>
@saFieldController = $controller 'saFieldController', { $scope: @scope }
Then => expect(@scope.showField).toEqual jasmine.any(Function)
describe 'calls $window.alert', ->
Given => inject ($rootScope, $controller, $window) =>
@scope = $rootScope.$new()
@windowService = $window
@saFieldController = $controller 'saFieldController', { $scope: @scope }
When => @scope.showField()
Then => expect(@windowService.alert).toHaveBeenCalled()
describe 'alert includes $scope.fieldName', ->
Given => inject ($rootScope, $controller, $window) =>
@scope = $rootScope.$new()
@scope.fieldName = "Test Name"
@windowService = $window
@saFieldController = $controller 'saFieldController', { $scope: @scope }
When => @scope.showField()
Then => expect(@windowService.alert.calls.mostRecent().args[0]).toMatch /.*Test Name.*/
describe 'alert includes $scope.fieldValue', ->
Given => inject ($rootScope, $controller, $window) =>
@scope = $rootScope.$new()
@scope.fieldValue = "Test Value"
@windowService = $window
@saFieldController = $controller 'saFieldController', { $scope: @scope }
When => @scope.showField()
Then => expect(@windowService.alert.calls.mostRecent().args[0]).toMatch /.*Test Value.*/
And there you have it. Some simple steps that make unit testing directives a little easier.
Finally…
In this post, we have taken a very brief look at how to structure a directive to simplify unit testing by separating the directive declaration from its controller and taking advantage of Angular services such as $window.
Although we have not covered some of the more complex directive concepts such as the linkĀ function and DOM manipulation, these simple steps should take you a long way towards providing better test coverage of your AngularJS widgets.
Until next time, take care and don't forget to leave a comment.