Trying to test AngularJS directives with a templateURL

angularjs, angularjs-directive, karma-runner, unit-testing

Solution

This has been an issue for us for a long time and I spent a few days to find out the solution - this it what we came up with. I will simply show you how our file structure is configured.

First off you will need to include karma-ng-html2js-preprocessor.

npm install karma-ng-html2js-preprocessor --save-dev

Next your karma.conf.js - you use coffee but i wont hold it against you ;-)

Remember to include the module name so you can inject it into the directive unit test.

// Karma configuration
// http://karma-runner.github.io/0.10/config/configuration-file.html

module.exports = function (config) {
    config.set({
        // base path, that will be used to resolve files and exclude
        basePath: '',

        // testing framework to use (jasmine/mocha/qunit/...)
        frameworks: ['jasmine'],

        preprocessors: {
            'app/views/templates/*.tpl.html': ['ng-html2js']  //<----- Also needed
        },

        // list of files / patterns to load in the browser
        files: [
            'app/bower_components/angular/angular.js',
            'app/bower_components/angular-mocks/angular-mocks.js',
            'app/bower_components/angular-resource/angular-resource.js',
            'app/bower_components/angular-cookies/angular-cookies.js',
            'app/bower_components/angular-sanitize/angular-sanitize.js',
            'app/bower_components/angular-bootstrap/ui-bootstrap.js',
            'app/bower_components/angular-ui-router/release/angular-ui-router.js',
            'app/bower_components/angular-local-storage/angular-local-storage.js',
            'app/bower_components/jquery/dist/jquery.js',
            'app/bower_components/bootstrap/dist/js/bootstrap.js',
            'app/scripts/*.js',
            'app/scripts/**/*.js',
            'test/spec/**/*.js',

            //Templates
            'app/views/templates/*.tpl.html' //<----- Also needed
        ],


        ngHtml2JsPreprocessor: {
            stripPrefix: 'app/',
            moduleName: 'Kinetix.Templates'   //<-----Module Name for injection
        },


        // list of files / patterns to exclude
        exclude: [],

        // Reporters
        reporters: ['progress', 'junit'],

        //Config for junit
        junitReporter: {
            outputFile: './test/test-results.xml',
            suite: ''
        },

        // web server port
        port: 9001,

        // level of logging
        // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
        logLevel: config.LOG_INFO,


        // enable / disable watching file and executing tests whenever any file changes
        autoWatch: false,


        // Start these browsers, currently available:
        // - Chrome
        // - ChromeCanary
        // - Firefox
        // - Opera
        // - Safari (only Mac)
        // - PhantomJS
        // - IE (only Windows)
        browsers: ['PhantomJS'],


        // Continuous Integration mode
        // if true, it capture browsers, run tests and exit
        singleRun: false
    });
};

So then after you got yer karma.conf.js set up lets have a look at a unit test. This is a directive unit test I will include the whole thing as well - maybe it can inspire your for other unit testing ideas as well. I have been at it now for almost 8 months and learned quite a bit...

Directive Unit Test:

'use strict';

describe('Directives: Search', function () {

    var//iable declarations
        elm,
        scope,
        $rootScope,
        $compile,
        $animate,
        ACCESS_LEVEL = [
            'OPEN',
            'PRIVATE',
            'RESTRICTED'
        ]
        ;

    beforeEach(function () {
        module('Kinetix.Constants');
        module('Kinetix.Templates');   //<------ See here we inject the templates!
        module('Kinetix.Directives.Search');
        module('Kinetix.Controllers.Search', function ($controllerProvider) {
            $controllerProvider.register('SearchController', function () { });
        });
    });

    beforeEach(inject(function (_$rootScope_, _$compile_, _$animate_) {
        $rootScope = _$rootScope_;
        scope = $rootScope;
        $animate = _$animate_;
        $compile = _$compile_;
    }));

    function setupDirective(accessLevel) {
        spyOn($animate, 'addClass').and.callThrough();
        spyOn($animate, 'removeClass').and.callThrough();

        $rootScope.accessLevel = { type: accessLevel };
        $rootScope.isAuthenticated = { value: false };

        elm = angular.element('<kx-search></kx-search>');
        $compile(elm)(scope);
        scope.$apply();
    }

    it('Should create the search template', function () {
        setupDirective(ACCESS_LEVEL[0]);
        var nav = $(elm).find('.nav');
        expect(nav.children('form')).toBeTruthy();
    });

    describe('Animations', function () {
        it('should have the fade slide class on setup with OPEN accesslevel', function () {
            setupDirective(ACCESS_LEVEL[0]);
            //With Authentication
            $rootScope.isAuthenticated.value = true;
            scope.$apply();
            expect(elm.children('div').hasClass('slide-left')).toBeTruthy();
            expect($animate.addClass).toHaveBeenCalled();

            $rootScope.isAuthenticated.value = false;
            scope.$apply();
            expect($animate.removeClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('slide-left')).toBeFalsy();
        });

        it('should toggle the fade-slide animation with PRIVATE acesslevels', function () {
            setupDirective(ACCESS_LEVEL[1]);
            expect($animate.addClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeTruthy();

            $rootScope.isAuthenticated.value = true;
            scope.$apply();
            expect($animate.removeClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeFalsy();

            $rootScope.isAuthenticated.value = false;
            scope.$apply();
            expect($animate.addClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeTruthy();
        });

        it('should toggle the fade-slide animation with RESTRICTED acesslevels', function () {
            setupDirective(ACCESS_LEVEL[2]);
            expect($animate.addClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeTruthy();

            $rootScope.isAuthenticated.value = true;
            scope.$apply();
            expect($animate.removeClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeFalsy();

            $rootScope.isAuthenticated.value = false;
            scope.$apply();
            expect($animate.addClass).toHaveBeenCalled();
            expect(elm.children('div').hasClass('fade-slide')).toBeTruthy();
        });
    });
});

For completion I will also include the directive itself just so you can see the complete picture.

angular.module('Kinetix.Directives.Search', [])

    .directive('kxSearch', function ($rootScope, $animate, PATH) {
        var linker = function (scope, el) {

            var//iable declarations
                accessLevel = $rootScope.accessLevel.type || 'Guest',
                element = el.children('.nav')
                ;

            //Check if the shop type is a PRIVATE or RESTRICTED type so we can chose which animation to apply
            if (accessLevel === 'RESTRICTED' || accessLevel === 'PRIVATE') {

                // Hide the element as we need authentication to show it
                $animate.addClass(element, 'fade-slide');

                $rootScope.$watch('isAuthenticated.value', function (newVal) {
                    if (!!newVal) {
                        $animate.removeClass(element, 'fade-slide');
                    }
                    if (!newVal) {
                        $animate.addClass(element, 'fade-slide');
                    }
                });
            }

            if (accessLevel === 'OPEN') {
                $rootScope.$watch('isAuthenticated.value', function (newVal, oldVal) {
                    if (newVal !== oldVal) {
                        if(!!newVal) {
                            $animate.addClass(element, 'slide-left');
                        }
                        if(!newVal) {
                            $animate.removeClass(element, 'slide-left');
                        }
                    }
                });
            }
        };

        return {
            restrict: 'E',
            link: linker,
            controller: 'SearchController',
            templateUrl: PATH.templates + 'search.tpl.html'
        };

    });

Hope it helps! This was the 800lb gorilla in the room for quite a while, once you actually tame him he is pretty sweet! Good luck!

Problem

In my `karma.conf.coffee`, I have: ``` files: [ 'public/bower_components/angular-mocks/angular-mocks.js' 'public/scripts/**/*.coffee' # not tested since configuration is difficult to be tested and perhaps should not be tested 'test/webapp/unit/**/*.coffee' 'views/templates/*.html' ] preprocessors: 'public/scripts/**/*.coffee': ['coverage'] 'test/webapp/unit/**/*.coffee': ['coffee'] 'views/templates/*.html': ['ng-html2js'] frameworks: ['jasmine'] ngHtml2JsPreprocessor: stripPrefix: 'views/' ``` In my test, I have: ``` describe('Directive: timespanSelector', function() { var scope; scope = null; beforeEach(module('myApp')); beforeEach(module('templates/partialDateRange.html')); beforeEach(function() { var html; html = "<timespan-selector></timespan-selector>"; return inject(function($compile, $rootScope) { var compiled, elem; scope = $rootScope.$new(); elem = angular.element(html); compiled = $compile(elem); compiled(scope); return scope.$digest(); }); }); return it('should test', function() {}); }); ``` When I run it, it says: ``` Error: [$injector:modulerr] Failed to instantiate module templates/partialDateRange.html due to: Error: [$injector:nomod] Module 'templates/partialDateRange.html' is not available! You either misspelled the module name or forgot to load it. If registering a module ensure that you specify the dependencies as the second argument. http://errors.angularjs.org/1.2.16/$injector/nomod?p0=templates%2FpartialDateRange.html ``` What am I doing wrong?

Original source