Testing Angular applications is multifaceted. On one hand, it’s fantastic that the Angular team places such a high emphasis on testing and provides tools to stub out various components of your application so that they can be tested in isolation. On the other hand, writing these tests is sometimes difficult, as the tools aren’t well documented, and different components are stubbed out or tested using different APIs that seem to elude memorization. In this post we’ll talk about one case that can be particularly frustrating to test: directives with dependencies.
Let’s say we have a group-activity-checkbox
directive. This directive is
unique in that it must be present within an activity-feed
directive. The body
of the directive is kept simple for illustration purposes:
groupActivityCheckboxDirective = ->
restrict: 'E'
require: '^activityFeed'
link: (scope, element, attrs, activityFeedCtrl) ->
activityFeedCtrl.updateActivities()
angular.module('socialnetwork')
.directive('groupActivityCheckbox', [
groupActivityCheckboxDirective
])
Now we want to write some tests for this directive. Generally directive tests follow this pattern:
- Stub any dependencies of the directive
- Compile the directive as an element
- Set something on the element, trigger an event, set something on the scope, etc., if necessary
- Make an assertion on the element’s attributes or content, or on a dependency being accessed in some way
In order to stub the dependencies on our group-activity-checkbox
directive, we
first need to identify those dependencies. You may think that we only have one
– the activity-feed
directive – but in fact we have two, because the
require
option will cause Angular to look for an ActivityFeedCtrl as well.
Given this, here’s what the pattern would look like for our directive:
- Stub
activity-feed
and ActivityFeedCtrl - Compile
group-activity-checkbox
in the context ofactivity-feed
- Assert that
updateActivities()
on the ActivityFeedCtrl instance is called
Now we can start writing the tests:
describe '<group-activity-checkbox>', ->
[$compile, $scope] = []
beforeEach ->
module 'socialnetwork', ->
# Stub ActivityFeedCtrl
# Stub <activity-feed>
return
inject (_$compile_, $rootScope) ->
$compile = _$compile_
$scope = $rootScope.$new()
compileDirective = ->
# Use the $compile service to compile <group-activity-checkbox> in
# the context of <activity-feed>, then return the element
it 'calls ActivityFeedCtrl#updateActivities', ->
# Use compileDirective to compile <group-activity-checkbox>
# Assert that updateActivities on our fake ActivityFeedCtrl is
# called
Notice that we’ve left out some pieces above. Let’s go about filling them in now.
Stubbing ActivityFeedCtrl
Angular gives us a way to register controllers using
$controllerProvider.register
. Re-registering a
controller results in overriding it. Given this, here’s how we’d stub
ActivityFeedCtrl:
[ActivityFeedCtrl] = []
beforeEach ->
class ActivityFeedCtrl
updateActivities: ->
module 'socialnetwork', ($controllerProvider) ->
$controllerProvider.register 'ActivityFeedCtrl', ActivityFeedCtrl
Stubbing <activity-feed>
Unfortunately, stubbing directives that are dependencies of other directives isn’t as straightforward as it seems.
Angular also gives us a way to register directives using
$compileProvider.directive
. This is the method called when
you register a directive with the angular.module(...).directive(...)
syntax,
and you can technically use it directly in tests, but doing so will lead to
frustration. One might think that, since $controllerProvider.register
and
other tools override a given component, $compileProvider.directive
works the
same way. But this isn’t true.
If we take a closer look at this method, here’s what we learn:
- A directive may have more than one instance. Calling
$compileProvider.directive
twice with the same name but different factories results in two instances of that directive being registered. - The first time you register a directive, Angular will register a corresponding service, named after the directive and suffixed with the word “Directive”. This service (a factory function) returns all of the instances of the directive in the form of configuration objects (which are then used later by Angular to compile the directive).
- Angular expects these configuration objects to be normalized. For instance, if
the
priority
of the directive has not been specified, it needs to default to 0, and if acontroller
has been specified andrequire
has not been specified,require
must be set to the name of the directive itself.
Given this information, let’s make a helper function that will allow us to
really override a directive. Note that we’ve simplified the logic in
$compileProvider.directive
– completing it is left as an exercise for the
reader – but it suits our needs just fine right now:
overrideDirective = ($provide, name, options = {}) ->
serviceName = name + 'Directive'
$provide.factory serviceName, ->
directive = angular.copy(options)
directive.priority ?= 0
directive.name = name
if !directive.require? && directive.controller?
directive.require = directive.name
[directive]
Now here’s how we’d use this function:
module 'socialnetwork', ($provide) ->
overrideDirective $provide, 'activityFeed',
restrict: 'E'
controller: 'ActivityFeedCtrl'
template: '<group-activity-checkbox>'
return
Filling in the remaining pieces
To complete our tests, we need to ensure that compileDirective
returns the
element that represents the group-activity-checkbox
directive. Because
group-activity-checkbox
has to be compiled in the context of activity-feed
,
we first compile activity-feed
and then pull group-activity-checkbox
out of
it. Then, we write the test itself, making use of compileDirective
.
Here is what our finished tests looks like:
overrideDirective = ($provide, name, options = {}) ->
serviceName = name + 'Directive'
$provide.factory serviceName, ->
directive = angular.copy(options)
directive.priority ?= 0
directive.name = name
if !directive.require? && directive.controller?
directive.require = directive.name
[directive]
describe '<group-activity-checkbox>', ->
[$compile, $scope, ActivityFeedCtrl] = []
beforeEach ->
class ActivityFeedCtrl
updateActivities: ->
module 'socialnetwork', ($controllerProvider, $provide) ->
$controllerProvider.register 'ActivityFeedCtrl', ActivityFeedCtrl
overrideDirective $provide, 'activityFeed',
restrict: 'E'
controller: 'ActivityFeedCtrl'
template: '<group-activity-checkbox>'
return
inject (_$compile_, $rootScope) ->
$compile = _$compile_
$scope = $rootScope.$new()
compileDirective = ->
parentElement = $compile('<activity-feed>')($scope)
parentElement.children().eq(0)
it 'calls ActivityFeedCtrl#updateActivities', ->
spyOn(ActivityFeedCtrl.prototype, 'updateActivities')
compileDirective()
expect(ActivityFeedCtrl.prototype.updateActivities).toHaveBeenCalled()
(For an interactive version, see the plunker.)