I've been following a pattern of Angular development of using components as much as possible, exclusively using controllerAs
, and following many recommendations in John Papa's styleguide.
The result is heavy use of directives and privately scoped controllers. This is great for organization, but this leaves the question on how to test what was previously controller code
This article assumes you have karma
and angular-mocks
already installed and configured.
In this example, I have a simple directive that renders a list of items, it uses another directive that represents each row in the grid (a nested directive).
The project structure looks like this:
/app/environments/list.directive.js
/app/environments/list.html
/app/environments/list-item.directive.js
/app/environments/list-item.html
/app/environments/list.directive.js
My directive declaration is fairly standard. It uses isolate scope with a single value, environments
, containing the elements in my list. The directive uses the standard controllerAs
format but has a privately scoped controller function. Finally, the directive loads its template from a Url.
The private controller, EnvironmentListController
doesn't do much in this case.
(function() {
'use strict';
angular
.module('app.environments')
.directive('environmentsList', environmentsList);
function environmentsList() {
return {
restrict: 'E',
scope: {
environments: '='
},
templateUrl: '/app/environments/list.html',
controller: EnvironmentListController,
controllerAs: 'vm',
bindToController: true
};
}
EnvironmentListController.$inject = [ ];
function EnvironmentListController() {
var vm = this;
vm.environments = vm.environments;
activate();
//////////
function activate() {
}
}
}());
/app/environments/list.html
The template file renders each item in the directive and calls another sub-directive to actually show some additional content.
<ul class="list-group environment-list">
<li ng-repeat="environment in vm.environments" class="list-group-item animate-show environment">
<environment-list-item environment="environment"></environemtn-list-item>
</li>
</ul>
Template Loading Gotchas
If you noticed the directive declaration, you'll see that we're referencing a template file via templateUrl
:
function environmentsList() {
return {
...
templateUrl: '/app/environments/list.html',
...
};
}
When you run tests, if your directive uses a template file, you will likely get the error:
Error: Unexpected request: GET /app/environments/list.html
You'll need to set up ngHtml2JsPreprocessor to fix this error. This preprocessor will take HTML files and convert them into JavaScript files. Each file creates a Service that can be injected into your test. This prevents Angular from loading the template. To accomplish this, you'll have to do the following:
Install karma-ng-html2js-preprocessor module
npm install karma-ng-html2js-preprocessor --save-dev
Add .html files to karma.conf.js
files: [
// js files go here
// add html files
'app/**/*.html',
]
Add preprocessor directive to karma.conf.js
preprocessors: {
'app/**/*.html': ['ng-html2js']
},
Add ngHtml2JsPreprocessor config to karma.conf.js
ngHtml2JsPreprocessor: {
moduleName: 'templates'
},
You will need to specify the module name that you will import in your test. I typically use templates
as the name of the module.
You may also need to add options for stripPrefix
and prependPrefix
to this configuration to match your application. For example, in the file system, I often use src/client
as the root for my Angular application files. I configure Express to serve these files from a virtiaul directory app
. So the file src/client/somefile.html
would get served as app/somefile.html
.
The preprocessor uses the file system path, not the served path as referenced in our application code. As a result I need to need to strip out the src/client
part and prepend /app
to match how the application code references the file. My config looks like this:
ngHtml2JsPreprocessor: {
moduleName: 'templates',
stripPrefix: 'src/client',
prependPrefix: '/app'
},
Writing the Directive Test
Now you should be all set up to write your test. The first thing we'll do is include our templates.
Add your modules
describe('environment-list Directive', function() {
beforeEach(module('templates'));
beforeEach(module('app.environment'));
});
Here, we add the modules that will be used by our test via angular-mocks
. The templates
modules is the html2Js output.
Load the Directive via Compiled HTML Code
beforeEach(inject(function($rootScope, $compile) {
element = angular.element('<environments-list environments="vm.environments"></environments-list>');
var scope = $rootScope;
scope.vm = {
environments: environments
};
$compile(element)(scope);
scope.$digest();
controller = element.controller('environmentsList');
}));
This code will create HTML to execute the directive. It will also grab a reference to the controller via the element.controller
method.
Write a test
describe('#activate', function() {
it('renders the environments', function() {
expect(controller.environments).to.be.an('array');
expect(controller.environments).to.equal(environments);
});
});
Mocking nested directives
The last remaining piece of the puzzle is mocking sub-directives. There is a great Stackoverflow discussion on this topic. I think the best solution is to manage sub-directives is to inject a mocked factory for the directive.
You can do this by creating a new factory in the module function. As noted in the above article, include Directive
beforeEach(module('app.environments', function($provide) {
$provide.factory('envirionmentListItemDirective', function() { return {}; });
}));
Simply create a factory for the name of your directive (append the word Directive in the name) of your directive since this is done internally by the compiler.
Full Test Code
Here's the final code for testing a Directive.
describe('environment-list Directive', function() {
beforeEach(module('templates'));
beforeEach(module('app'));
beforeEach(module('app.environments', function($provide) {
$provide.factory('environmentListItemDirective', function() { return {}; });
}));
var element
, environments = [ {} ]
, controller
;
beforeEach(inject(function($rootScope, $compile) {
element = angular.element('<environments-list environments="vm.environments"></environments-list>');
var scope = $rootScope;
scope.vm = {
environments: environments
};
$compile(element)(scope);
scope.$digest();
controller = element.controller('environmentsList');
}));
describe('#activate', function() {
it('renders the environments', function() {
expect(controller.environments).to.be.an('array');
expect(controller.environments).to.equal(environments);
});
});
});