Simplifying AngularJS Unit Testing with Karma on Rails

Jan Dudulski

Simplifying AngularJS Unit Testing with Karma on Rails

TL;DR

We have prepared a sample app with nothing more than the necessary configuration and a single, oversimplified spec file. You can find it on github now or first you can get more details about it below.

Background

Karma is a test runner designed for the purpose of AngularJS unit testing. It works with Jasmine, Mocha and other players but configuring it to work nicely inside a Rails environment and with sprockets is a bit tricky. In this post I will show you how we set it up based on our Angular with Rails marriage setup.

We base our setup mostly on the Angular + Rails with no fuss article by Sebastien Saunier which is another good read on the topic.

Requirements

First we need to setup a list of needed npm packages:

{ "name": "app", "dependencies": { "karma": ">= 0.12.16", "karma-jasmine": ">= 0.2.2", "karma-coffee-preprocessor": ">= 0.2.1", "karma-slim-preprocessor": ">= 0.0.1", "karma-phantomjs-launcher": ">= 0.1.4", "karma-ng-html2js-preprocessor": "git://github.com/monterail/karma-ng-html2js-preprocessor#feature/strip-sufix" } } 

I think karma, karma-jasmine and karma-phantomjs-launcher don't need any explanation. Karma-coffee-preprocessor and karma-slim-preprocessor will help us to compile coffee and slim templates during the testing phase. The most interesting things happen in karma-ng-html2js-preprocessor which compiles templates on the fly and keeps them in cache, but I will describe that in more detail later.

Karma config

We keep karma configuration under spec/karma/config/unit.js and it's almost the same as the original one generated by karma itself:

module.exports = function(config) {
  config.set({

    // base path
    basePath: '../',

    // frameworks to use
    frameworks: ['jasmine'],

    // list of files / patterns to load in the browser
    files: [
      APPLICATION_SPEC,
      'app/assets/templates/**/*.html.slim',
      'spec/javascripts/**/*_spec.{coffee,js}'
    ],

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

    // test results reporter to use
    // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
    reporters: ['progress'],

    // web server port
    port: 9876,

    // enable / disable colors in the output (reporters and logs)
    colors: true,

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

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

    // Start these browsers, currently available:
    // - Chrome
    // - ChromeCanary
    // - Firefox
    // - Opera (has to be installed with `npm install karma-opera-launcher`)
    // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`)
    // - PhantomJS
    // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`)
    browsers: ['PhantomJS'],

    // If browser does not capture in given timeout [ms], kill it
    captureTimeout: 60000,

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

    // Preprocessors
    preprocessors: {
      '**/*.coffee': ['coffee'],
      '**/*.slim': ['slim', 'ng-html2js']
    },

    ngHtml2JsPreprocessor: {
      stripPrefix: 'app/assets/templates/',
      stripSufix: '.slim'
    }
  });
};

Now, let's discuss the most significant places:

basePath: '../',

When we run our specs we will build final configuration in tmp directory so ../ leads to the root path of our application.

files: [
  APPLICATION_SPEC,
  'app/assets/templates/**/*.slim',
  'spec/javascripts/**/*_spec.{coffee,js}'
]

APPLICATION_SPEC will be replaced with a list of our JavaScript assets managed by sprockets. Next we'll find every *.html.slim file under assets/templates and the final line will look for specs.

preprocessors: {
  '**/*.coffee': ['coffee'],
  '**/*.slim': ['slim', 'ng-html2js']
}

Here we're saying that we want to pass *.coffee files to coffee-script preprocessor and *.slim templates through slim preprocessor and then, parsed to plain html, to ng-html2js preprocessor which has its own, additional configuration:

ngHtml2JsPreprocessor: {
  stripPrefix: 'app/assets/templates/',
  stripSufix: '.slim'
}

Karma doesn't allow us to make any non-mocked http requests in unit tests including GET requests for templates. ng-html2js will keep templates in $templateCache but we need to remove app/assets/templates prefix from the path and .slim extension so they will be accessible under the same path as with Rails (if you don't use our interceptor you will need to add prependPrefix: '/assets/' too).

Note about templates

Unfortunately ng-html2js doesn't allow us to stripSufix (at least - yet) so we had to prepare our own PR for this. Until it is merged, you can use our fork. To use slim in templates you need to create this initializer:

Rails.application.assets.register_mime_type 'text/html', '.html'
Rails.application.assets.register_engine '.slim', Slim::Template

Final steps

The last thing that we need is a way to run karma with sprockets assets injected. Here is a rake task to make this possible:

namespace :karma  do
  task :start => :environment do
    with_tmp_config :start
  end

  task :run => :environment do
    exit with_tmp_config :start, "--single-run"
  end

  private

  def with_tmp_config(command, args = nil)
    Tempfile.open('karma_unit.js', Rails.root.join('tmp')) do |f|
      f.write unit_js(application_spec_files)
      f.flush

      system "./node_modules/karma/bin/karma #{command} #{f.path} #{args}"
    end
  end

  def application_spec_files
    Rails.application.assets.find_asset("application_spec.js").to_a.map {|e| e.pathname.to_s }
  end

  def unit_js(files)
    unit_js = File.open('spec/karma/config/unit.js', 'r').read
    unit_js.gsub "APPLICATION_SPEC", ""#{files.join("","")}""
  end
end

It will provide two commands: karma:run will run all the specs once and karma:start will run them after each change in specs. Both will use a temporary file as config generated by mixing our unit.js with the list of assets read with sprockets from application_spec.js:

// spec/karma/application_spec.js
//= require application
//= require angular-mocks

Spec itself

In our example repo, you can find the simplest possible directive which renders a list of countries. Here is our pseudo-spec:

describe 'Countries Directive', ->
  $compile = $scope = $httpBackend = element = null

  beforeEach ->
    shared = angular.module('shared')
    shared.constant('Rails', { env: 'test', templates: {} })

    module('application', 'countries.html')

  beforeEach(inject((_$compile_, _$rootScope_, _$httpBackend_) ->
    $scope = _$rootScope_
    $compile = _$compile_
    $httpBackend = _$httpBackend_
  ))

  beforeEach ->
    $scope.countries = [{
      code: 'PL',
      name: 'Poland'
    }, {
      code: 'UK',
      name: 'United Kingdom'
    }, {
      code: 'IT',
      name: 'Italy'
    }]
    element = $compile('<countries data="countries"></countries>')($scope)
    $scope.$digest()

  it 'renders list of countries', ->
    expect(element.html()).toContain('Poland')

And again, let's discuss this step by step.

beforeEach ->
  shared = angular.module('shared')
  shared.constant('Rails', { env: 'test', templates: {} })

  module('application', 'countries.html')

Our first beforeEach block mocks two things: the shared module with templates hash for interceptor (we won't use it here so it is just empty) and angular-mocks module which registers our application module and template which we use in our directive (remember ng-html2js? now it loads the template into cache).

beforeEach(inject((_$compile_, _$rootScope_, _$httpBackend_) ->
  $scope = _$rootScope_
  $compile = _$compile_
  $httpBackend = _$httpBackend_
))

Inject, another angular-mock feature, will give us access to any module from the angular which we would need to setup specs.

beforeEach ->
  $scope.countries = [{
    code: 'PL',
    name: 'Poland'
  }, {
    code: 'UK',
    name: 'United Kingdom'
  }, {
    code: 'IT',
    name: 'Italy'
  }]
  element = $compile('<countries data="countries"></countries>')($scope)
  $scope.$digest()

And the final beforeEach will set mock data into $scope and compile a mock template on which our directive should be run. The spec itself is self-explanatory.

Bonus

If you use circle CI you can easily bind our karma rake runner there - just add test post to the circle.yml and make sure that tmp (for final config) will be there:

checkout:
  post:
    - mkdir -p tmp

test:
  post:
    - bundle exec rake karma:run

For travis it should look similar.

Angular Tips recently published a series of introductory articles on how to write Unit Tests for Angular: services, controllers, directives and some conclusions.

Happy coding!