Redakted - a blog about all things dev
Migrating AngularJS tests From Karma to Jest
27.04.201821 Min Read — In coding

Disclaimer

This is just a write up of how we migrated our angularJS tests to Jest at my company, it's not a guide and there certainly are elements that I won't cover but I hope it helps somebody out there.

In this post it is assumed that you have some good knowlegde in javascript unit testing configuration using karma, that you have heard or know of Jest and that NPM, Babel, Typescript are familiar to you.

This article explains what Jest and some other testing tools are pretty well.

Context

At the end of 2017, we started migrating our whole front-end codebase to Typescript and webpack. In my opinion, Typescript is a godsend for large enterprise javascript code bases and we appreciate it. The old setup was getting complicated to maintain as it was custom made and didn't really follow javascript best practices.

Our front-end code is divided into two parts:

  • a library of reusable components that we use internally across all applications
  • the code that uses the lib and contains the application specific elements (views, custom components, services etc....)

The old setup required us to build the lib and then build the app that depended on it and a whole bunch of stuff that would give you nightmares; we even had "black magic" written in some places. If you ever come across such things in a codebase, it doesn't smell good. I reckon even you agree we had to change stuff and change we did.

To get back on topic, after having setup our new build with webpack and converted our js files to typescript; there was one last thing to do: make the tests work again !

Ah, didn't I tell you that ? Well in addition to what was previously mentionned, our tests weren't running anymore as a side effect of all the custom stuff.

The test setup was based on Karma/Mocha/Grunt/PhantomJS and just didn't work anymore. My first reflex was to update the tests to make them work with karma but that proved a more daunting task than I anticipated it to be. The plugin system of karma can be cool but this time it was being more of a hassle than anything. I tried karma-typescript (really nice lib and awesome maintainer, shout out to @monounity); at first it went well, a majority of the library tests worked and all that but when I tried to run the application tests all hell broke loose. We used namespaces for the lib and karma-typescript didn't really like it so I opened an issue that monouty fixed but I then ran into other problems.

In light of all these issues, I couldn't make it work on time and had to leave it alone for a while, there were other things that needed my attention unfortunately. Fast forward to April 12th 2018, I was attending a meetup with a friend that was about TDD and BDD (an article about the meetup in french but with slides in english link) and they used Jest (woohoo, he's finally talking about it). I had heard of the framework and read this good article on using it for angular apps. This reminded me of my unfinished business with karma. I pitched Jest to my team and given my previous run-ins with karma, we decided to go ahead and migrate (don't know till you try) all our tests to it.

Migration

karma config

Here are the karma config files we used. The first one is for the app and there wasn't any attempt to make it work. The second one is for our internal library and is the one that I tried to make work.

var _ = require('lodash');
var appConfig = require('./app-config.json');
// Check for custom app configuration, override if necessary
try {
var customConfig = require('./app-config-custom.json');
_.merge(customConfig, appConfig, _.defaults);
appConfig = customConfig;
} catch (e) {
// swallow
}
module.exports = function(config) {
// Load JSKit RequireJS configuration
require('./tmp/lib/jskit/config.js');
var jskitRequireConfig = requirejs.config();
// Load App RequireJS configuration
require(SRC_DIR + '/config.js');
var appRequireConfig = requirejs.config();
var karmaConf = {
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha', 'requirejs', 'chai', 'sinon-chai', 'chai-as-promised','es6-shim'],
// list of files / patterns to load in the browser
files: [
'test-main.js', {
pattern: SRCTEST_DIR + '/**',
included: false
}, {
pattern: BUILDTEST_DIR + '/**',
included: false
}
],
// list of files to exclude
exclude: [],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: { /*set below */ },
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'coverage', 'junit'],
junitReporter: {
outputFile: 'test-results.xml',
suite: '[JS] ' + appConfig.testSuite + ' - '
},
// optionally, configure the reporter
coverageReporter: {
reportBasePath: appConfig.coverageBasePath || '.',
type: 'lcov',
dir: 'coverage/',
subdir: '.'
},
// 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,
//Polling bypass the filesystem's change event and instead use polling within Karma; more memory use,
// but better at detecting changes
usePolling: true,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
//browsers: ['Chrome', 'Firefox', 'PhantomJS'],
browsers: ['PhantomJS'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: false,
client: {
jskitRequireConfig: jskitRequireConfig,
appRequireConfig: appRequireConfig,
captureConsole: true,
useIframe: true
}
};
karmaConf.preprocessors[BUILDTEST_DIR + '/*.js'] = ['coverage'];
karmaConf.preprocessors[BUILDTEST_DIR + '/!(lib)/**/*.js'] = ['coverage'];
config.set(karmaConf);
};
view raw app-karma-config.js hosted with ❤ by GitHub

var tsconfig = require('./tsconfig.json');
// set global environment variable CHROME_BIN=chromium-browser
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['mocha', 'chai', 'sinon-chai', 'chai-as-promised', 'karma-typescript'],
files: [
'src-test/**/*.ts',
'node_modules/jquery/dist/jquery.js',
'node_modules/angular/angular.js',
'node_modules/angular-i18n/angular-locale_en.js',
'node_modules/angular-i18n/angular-locale_fr.js',
{ pattern: 'src/**/*.ts', served: true, included: true }
],
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'karma-typescript' ],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
'src/**/*.ts': ['karma-typescript'],
'src-test/**/*.ts': ['karma-typescript']
},
karmaTypescriptConfig: {
compilerOptions: {
target: 'ES5',
module: 'commonjs',
sourceMap: true,
inlineSources: true,
declaration: true,
allowSyntheticDefaultImports: true,
emitDecoratorMetadata: true,
experimentalDecorators: true,
moduleResolution: 'node',
baseUrl: '.',
paths: {
'ot/*': ['./src/*'],
},
exclude: ["node_modules", "dist",]
},
reports: {
'lcovonly': 'test_reports/coverage',
'html': 'test_reports/html',
'text-summary': ''
},
coverageOptions: {
exclude: /\.(d|Spec)\.ts?/,
},
bundlerOptions: {
transforms: [
require('karma-typescript-es6-transform')()
],
entrypoints: /\Spec\.ts$/,
resolve: {
directories: ['node_modules']
}
}
},
plugins: [
require('karma-mocha'),
require('karma-chai'),
require('karma-chai-plugins'),
require('karma-coverage'),
require('karma-chrome-launcher'),
require('karma-typescript'),
require('karma-typescript-es6-transform'),
require('karma-firefox-launcher'),
require('karma-sinon-chai'),
],
port: 9876, // karma web server port
colors: true,
logLevel: config.LOG_INFO,
browsers: ['ChromeHeadless','Chrome'],
autoWatch: false,
singleRun: true, // Karma captures browsers, runs the tests and exits
concurrency: Infinity,
})
}
view raw lib-karma-config.js hosted with ❤ by GitHub

Jest configuration

I started reading the official documentation (who said devs don't read the manual?) and there was a section about testing web frameworks that led to my previously mentioned article and this lifesaving piece by @benbrandt. There aren't many articles about Jest + Angular out there and trust me you need it when doing this kind of migration.

Typescript

We are using Typescript and Jest doesn't natively support it so we need a preprocessor to do the job. Enter TS-Jest, it does it all for you.

So we end up with a transform that looks like this.

    "transform": {
      "^.+\\.ts?$": "ts-jest",
    },

I also had to create a seperate tsconfig file for ts-jest because it doesn't support all the options that we use in our typescript config file. I also disabled the TsDiagnostics but you shouldn't

    "globals": {
      "ts-jest": {
        "tsConfigFile": "test-tsconfig.json",
        "enableTsDiagnostics": false
      }
    }

Namespace

I read the articles for a bit, and started creating the configuration file for the library tests. The first problem I ran into was managing our namespace. After reading the docs for a while, I saw the moduleNameMapper option and that was it, problem solved.

    "moduleNameMapper": {
      "customNamespace/(.*)$": "<rootDir>/src/$1",
    },

Loading html files

We use webpack for our build and load html files using webpack's html-loader. I needed the same functionality for the tests. A couple of google searches later I found this stackoverflow issue. After reading all the comments and answers, I decided to follow their advice and create a custom preprocessor for Jest (yes Jest allows you to do that).

const htmlLoader = require('html-loader');
module.exports = {
process(src, filename, config, options) {
return htmlLoader(src);
}
}
view raw htmlLoader.ts hosted with ❤ by GitHub

All that's left is to include it in the config.

    "transform": {
      "^.+\\.ts?$": "ts-jest",
      "^.+\\.html$": "<rootDir>/src-test/utils/htmlLoader.ts"
    },

Making sure img tag doesn't break my tests

In some of our html templates, we directly import images and that doesn't work with Jest; you need to stub it. Searching on the internet brought me to this package jest-static-stubs that is just perfect for the job. In the moduleNameMapper section of the config we just add the right line:

    "moduleNameMapper": {
      "customNameSpace/(.*)$": "<rootDir>/src/$1",
      "^.+\\.(jpg|jpeg|gif|png|mp4|mkv|avi|webm|swf|wav|mid)$": "jest-static-stubs/$1"
    }

Angular-mocks and global jquery

Due to how certain things work with angular ( this is better explained in mr brandt's article) we have to expose certain values (Jquery, Angular) on the global scope. In addition to that we need to import angular-mocks so that Angular sets up the app prior to running the tests. This is all in the form of an init file that is later referenced in the Jest configuration.

Init file content:

import * as angular from "angular";
import jQuery from 'jquery';
Object.defineProperty(window, "jQuery", { value: jQuery });
Object.defineProperty(window, "$", { value: jQuery });
Object.defineProperty(window, "angular", { value: angular });
import "angular-mocks";
view raw init.ts hosted with ❤ by GitHub

Referencing the init file in Jest config:

"setupTestFrameworkScriptFile": "<rootDir>/src-test/utils/init.ts",

Library config

We ended up with a configuration for Jest like this in our package.json:

"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"<rootDir>/src/**/*.ts",
"!<rootDir>/src/*.d.ts"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"/src-test/",
"/dist/"
],
"globals": {
"ts-jest": {
"tsConfigFile": "test-tsconfig.json",
"enableTsDiagnostics": false
}
},
"moduleDirectories": [
"node_modules",
"<rootDir>/src/"
],
"moduleFileExtensions": [
"ts",
"js",
"html"
],
"moduleNameMapper": {
"ot/(.*)$": "<rootDir>/src/$1",
"^.+\\.(jpg|jpeg|gif|png|mp4|mkv|avi|webm|swf|wav|mid)$": "jest-static-stubs/$1"
},
"setupTestFrameworkScriptFile": "<rootDir>/src-test/utils/init.ts",
"transform": {
"^.+\\.(j|t)s?$": "ts-jest",
"^.+\\.html$": "<rootDir>/src-test/utils/htmlLoader.ts"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(ts?)$",
"testPathIgnorePatterns": [
"/node_modules/"
],
"verbose": true
},
view raw lib-jest.json hosted with ❤ by GitHub

That was it for our library tests and we even had code coverage without adding anything else. The cherry on top for me as a vs-code fanboy is the existence of this extension. The extension is pretty cool and I would recommend checking it out if you use vs-code and Jest.

Moving on to the application tests, I thought it would be a straightforward copy-paste and adapt thing.... Little did I know other issues were awaiting.

es6 module support

As previously stated, our code is split into two parts: a library that is an npm module and the applications that depend on it. The library is written in typescript and we compile to es6. I needed to configure Jest to correctly load es6 modules and this issue had the answer somewhere in the thread. The solution was to use babel-jest for js files (my node_modules in this case) and to add a .babelrc file to my project containing:

{
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"]
}
}
}
view raw babelrc.json hosted with ❤ by GitHub

The transform part of Jest config became :

    "transform": {
      "^.+\\.js?$": "babel-jest",
      "^.+\\.ts?$": "ts-jest",
      "^.+\\.html$": "<rootDir>/src-test/utils/htmlLoader.ts"
    },

The final Jest config is not that different from the lib one :

"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"<rootDir>/src/**/*.ts",
"!<rootDir>/src/*.d.ts"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"/src-test/",
"/dist/"
],
"globals": {
"ts-jest": {
"tsConfigFile": "test-tsconfig.json",
"enableTsDiagnostics": false
},
"NODE_ENV": "test"
},
"moduleDirectories": [
"node_modules",
"<rootDir>/src/"
],
"moduleFileExtensions": [
"ts",
"js",
"html"
],
"moduleNameMapper": {
"customNamespace/(.*)$": "<rootDir>/node_modules/jskitx/$1",
"^.+\\.(jpg|jpeg|gif|png|mp4|mkv|avi|webm|swf|wav|mid)$": "jest-static-stubs/$1"
},
"setupTestFrameworkScriptFile": "<rootDir>/src-test/utils/init.ts",
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(ts?)$",
"testPathIgnorePatterns": [
"/node_modules/"
],
"transform": {
"^.+\\.js?$": "babel-jest",
"^.+\\.ts?$": "ts-jest",
"^.+\\.html$": "<rootDir>/src-test/utils/htmlLoader.ts"
},
"transformIgnorePatterns": [
"/node_modules/(?!jskitx)"
],
"verbose": true
}
view raw app-jest-config.json hosted with ❤ by GitHub

Comparison with Karma

The performance between Jest and Karma cannot be compared as there is no reference from the time the karma tests worked. I can tell you that Jest takes 38.425 seconds to run 92 tests organised in 9 test suites and run coverage. We went from 13 to 4 dependencies (jest, ts-jest, babel-jest, jest-static-stubs) needed to run our tests. PhantomJS is not needed anymore since Jest uses JSdom; that can be seen as an advantage or a disadvantage since we are no longer testing against real browsers. I hope testing against real browsers will be an option for Jest in the futur.

Conclusion

It wasn't easy but in my opinion it was worth it; we now have a more maintanable and modern test configuration. Testing can be fun with the right tools and I hope that we can add to our test base on a more regular basis with this setup.

A big thanks to the open source community without which this wouldn't have been half as easy. Hope this helps you.

A big thanks to Steven, Sam, Jean-Baptiste for advice and editing.

Photo credit goes to @weilstyle on Unsplash.