ยท testing

TypeScript code coverage with Karma

To obtain coverage reports for code running in web browsers, you can configure code coverage with TypeScript and Karma.

Configuring code coverage with TypeScript and Karma to get coverage reports for code running in web browsers.

Contents

Environment

Tested with:

  • Node.js v10.9.0
  • yarn v1.15.2

Starter code

package.json
{
  "devDependencies": {
    "jasmine": "3.4.0"
  },
  "main": "src/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "node index.js",
    "test": "jasmine --config=jasmine.json"
  },
  "version": "0.0.0"
}
jasmine.json
{
  "random": true,
  "spec_dir": "src",
  "spec_files": ["**/*test.js"],
  "stopSpecOnExpectationFailure": true
}
index.js
const afterTwoSeconds = require('./src/main');
 
afterTwoSeconds(() => {
  console.log('I will be called after 2 seconds.');
});
src/main.js
module.exports = function afterTwoSeconds(callback) {
  return new Promise((resolve) => {
    setTimeout(() => {
      callback();
      resolve();
    }, 2000);
  });
};
src/main.test.js
const afterTwoSeconds = require('./main');
 
describe('afterTwoSeconds', () => {
  it('resolves after 2 seconds', async () => {
    const myCallbackSpy = jasmine.createSpy('myCallbackSpy');
    await afterTwoSeconds(myCallbackSpy);
    expect(myCallbackSpy).toHaveBeenCalled();
  });
});

Add Karma

Update dependencies

Karma needs an adapter to know about the Jasmine testing framework. It also needs a browser launcher to run the tests within a browser environment.

package.json
{
  "devDependencies": {
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-jasmine": "2.0.1"
  },
  "main": "src/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "node index.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Update export

Karma will run the tests inside the operating system's Chrome browser which was in my case Chrome v74. The browser environment does not know about module.exports, so we will use the window namespace to export our afterTwoSeconds function:

src/main.js
function afterTwoSeconds(callback) {
  return new Promise((resolve) => {
    setTimeout(() => {
      callback();
      resolve();
    }, 2000);
  });
}
 
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
  module.exports = afterTwoSeconds;
} else {
  window.afterTwoSeconds = afterTwoSeconds;
}

Note: We still export our code for Node.js environments, to make our code work in both worlds. That's why we keep setTimeout because it is available in Node.js and browser environments. If we would write window.setTimeout it would only work in browsers but fail in Node.js.

Update test

Our tests will run in the browser so we cannot import code with a require statement (CommonJS syntax) anymore and need to use the window namespace:

src/main.test.js
describe('afterTwoSeconds', () => {
  it('resolves after 2 seconds', async () => {
    const myCallbackSpy = jasmine.createSpy('myCallbackSpy');
    await window.afterTwoSeconds(myCallbackSpy);
    expect(myCallbackSpy).toHaveBeenCalled();
  });
});

Add Karma configuration

A Karma configuration can be interactively created by running npx karma init. In our case we reuse the file paths from our Jasmine configuration.

For a successful Karma test run it is important to declare the source code and test code within the files property.

Karma can be equipped with a custom test reporter but for now we are good with the standard progress reporter:

karma.conf.js
const jasmineConfig = require('./jasmine.json');
 
module.exports = function (config) {
  config.set({
    autoWatch: false,
    basePath: jasmineConfig.spec_dir,
    browsers: ['Chrome'],
    colors: true,
    concurrency: Infinity,
    exclude: [],
    files: ['main.js', ...jasmineConfig.spec_files],
    frameworks: ['jasmine'],
    logLevel: config.LOG_INFO,
    port: 9876,
    preprocessors: {},
    reporters: ['progress'],
    singleRun: true,
  });
};

Add Webpack

Update dependencies

We need to add webpack-karma so that Karma can use webpack to preprocess files. This also requires us to include webpack in our list of dependencies as it is a peer dependency of webpack-karma:

package.json
{
  "devDependencies": {
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-jasmine": "2.0.1",
    "karma-webpack": "3.0.5",
    "webpack": "4.30.0"
  },
  "main": "src/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "node index.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Common mistake

ERROR [preprocess]: Can not load "webpack", it is not registered! Perhaps you are missing some plugin?

This happens when you run karma start and you forgot to install webpack.

Add Webpack configuration

Thanks to webpack's zero configuration mode and its default settings, we don't need to specify much. All we do is defining a "development" mode to get detailed messages in case of preprocessing errors:

webpack.config.js
module.exports = {
  mode: 'development',
};

Using a "development" mode will decrypt error messages like TypeError: r is not a function.

Update Karma configuration

In the previous Karma setup, our test code was relying that our business logic is exposed to the window namespace (window.afterTwoSeconds). Having webpack in place we will now load our business logic through our test code. That's why we don't need to declare our business logic anymore within Karma's files pattern. It's sufficient if we just point Karma to our test code because the tests will import the main source code for us. We can also reuse our webpack configuration by requiring it. To activate webpack, we need to declare it as a preprocessor for our test code. We also need to add the webpack configuration to our Karma configuration:

karma.conf.js
const testCode = 'src/**/*test.js';
const webpackConfig = require('./webpack.config.js');
 
module.exports = function (config) {
  config.set({
    autoWatch: false,
    basePath: '',
    browsers: ['Chrome'],
    colors: true,
    concurrency: Infinity,
    exclude: [],
    files: [
      {pattern: testCode, watched: false}
    ],
    frameworks: ['jasmine', 'webpack],
    logLevel: config.LOG_INFO,
    port: 9876,
    preprocessors: {
      [testCode]: ['webpack']
    },
    reporters: ['progress'],
    singleRun: true,
    webpack: webpackConfig
  });
};

Update imports

Our test code will now be preprocessed by webpack which means that we can use Node.js features like require statements to import code. Webpack will make sure that the require statements get processed into something that can be understood by our Browser environment:

karma.conf.js
const afterTwoSeconds = require('./main');
 
describe('afterTwoSeconds', () => {
  it('resolves after 2 seconds', async () => {
    const myCallbackSpy = jasmine.createSpy('myCallbackSpy');
    await afterTwoSeconds(myCallbackSpy);
    expect(myCallbackSpy).toHaveBeenCalled();
  });
});

Add Babel

Babel 7 ships with TypeScript support and can be used to preprocess code with TypeScript's compiler. You might not need Babel to compile your code with TypeScript but using Babel's ecosystem (with presets like @babel/preset-env) can bring enormous benefits if you want to ship code for various environments. That's why it is the preferred setup in this tutorial, so let's get started with a Babel setup:

Update dependencies

yarn add @babel/core babel-loader --dev
package.json
{
  "devDependencies": {
    "@babel/core": "7.4.4",
    "babel-loader": "8.0.5",
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-jasmine": "2.0.1",
    "karma-webpack": "3.0.5",
    "webpack": "4.30.0"
  },
  "main": "src/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "node index.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Add Babel configuration

We will start with a very basic Babel configuration which does not define any plugin our sets of plugins (called presets). Without plugins Babel won't do much which is okay for now and will be changed once we add TypeScript to our Babel toolchain.

babel.config.js
module.exports = {
  plugins: [],
  presets: [],
};

Update webpack configuration

With the babel-loader we are telling webpack to process files ending on .js or .jsx (/\.jsx?$/) through Babel:

webpack.config.js
module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        exclude: /(node_modules)/,
        loader: 'babel-loader',
        test: /\.jsx?$/,
      },
    ],
  },
};

Add TypeScript

Update dependencies

yarn add @babel/preset-typescript @types/jasmine @types/node typescript --dev
package.json
{
  "devDependencies": {
    "@babel/core": "7.4.4",
    "@babel/preset-typescript": "7.3.3",
    "@types/jasmine": "3.3.12",
    "@types/node": "12.0.0",
    "babel-loader": "8.0.5",
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-jasmine": "2.0.1",
    "karma-webpack": "3.0.5",
    "typescript": "3.4.5",
    "webpack": "4.30.0"
  },
  "main": "src/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "node index.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Add TypeScript configuration

tsc --init
tsconfig.json
{
  "compilerOptions": {
    "esModuleInterop": true,
    "lib": ["es6"],
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "target": "es6"
  }
}

Update webpack configuration

webpack.config.js
module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        exclude: /(node_modules)/,
        loader: 'babel-loader',
        test: /\.[tj]sx?$/,
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
};

Update Babel configuration

babel.config.js
module.exports = {
  plugins: [],
  presets: ['@babel/preset-typescript'],
};

Migrate test code

We need to rename main.test.js to main.test.ts. Thanks to the allowJs TypeScript compiler option we can still import our JavaScript business logic within our test code:

src/main.test.ts
const afterTwoSeconds = require('./main');
 
describe('afterTwoSeconds', () => {
  it('resolves after 2 seconds', async () => {
    const myCallbackSpy = jasmine.createSpy('myCallbackSpy');
    await afterTwoSeconds(myCallbackSpy);
    expect(myCallbackSpy).toHaveBeenCalled();
  });
});

Update Karma configuration

Our Karma setup now needs to load our migrated test code:

karma.conf.js
const testCode = 'src/**/*test.ts';
const webpackConfig = require('./webpack.config.js');
 
module.exports = function (config) {
  config.set({
    autoWatch: false,
    basePath: '',
    browsers: ['Chrome'],
    colors: true,
    concurrency: Infinity,
    exclude: [],
    files: [{ pattern: testCode, watched: false }],
    frameworks: ['jasmine'],
    logLevel: config.LOG_INFO,
    port: 9876,
    preprocessors: {
      [testCode]: ['webpack'],
    },
    reporters: ['progress'],
    singleRun: true,
    webpack: webpackConfig,
  });
};

Migrate source code

Update export

src/main.ts
export function afterTwoSeconds(callback: Function) {
  return new Promise((resolve) => {
    setTimeout(() => {
      callback();
      resolve();
    }, 2000);
  });
}

Update import

src/main.test.ts
import { afterTwoSeconds } from './main';
 
describe('afterTwoSeconds', () => {
  it('resolves after 2 seconds', async () => {
    const myCallbackSpy = jasmine.createSpy('myCallbackSpy');
    await afterTwoSeconds(myCallbackSpy);
    expect(myCallbackSpy).toHaveBeenCalled();
  });
});

Adjust start script

package.json
{
  "devDependencies": {
    "@babel/core": "7.4.4",
    "@babel/preset-typescript": "7.3.3",
    "@types/jasmine": "3.3.12",
    "@types/node": "12.0.0",
    "babel-loader": "8.0.5",
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-jasmine": "2.0.1",
    "karma-webpack": "3.0.5",
    "typescript": "3.4.5",
    "webpack": "4.30.0"
  },
  "main": "dist/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "tsc && node dist/main.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Add code coverage

Note: Every package prefixed with karma- will be automatically added to Karma's plugin section, so no need to define it.

Update dependencies

yarn add istanbul-instrumenter-loader karma-coverage-istanbul-reporter --dev

Update Karma configuration

yarn add istanbul-instrumenter-loader karma-coverage-istanbul-reporter --dev
package.json
{
  "devDependencies": {
    "@babel/core": "7.4.4",
    "@babel/preset-typescript": "7.3.3",
    "@types/jasmine": "3.3.12",
    "@types/node": "12.0.0",
    "babel-loader": "8.0.5",
    "istanbul-instrumenter-loader": "3.0.1",
    "jasmine": "3.4.0",
    "karma": "4.1.0",
    "karma-chrome-launcher": "2.2.0",
    "karma-coverage-istanbul-reporter": "2.0.5",
    "karma-jasmine": "2.0.1",
    "karma-webpack": "3.0.5",
    "typescript": "3.4.5",
    "webpack": "4.30.0"
  },
  "main": "dist/main.js",
  "name": "karma-webpack-babel-typescript-istanbul",
  "scripts": {
    "start": "tsc && node dist/main.js",
    "test": "karma start"
  },
  "version": "0.0.0"
}

Update webpack configuration

webpack.config.js
module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        exclude: /(node_modules)/,
        loader: 'babel-loader',
        test: /\.[tj]sx?$/,
      },
      {
        enforce: 'post',
        exclude: /(node_modules|\.test\.[tj]sx?$)/,
        test: /\.[tj]s$/,
        use: {
          loader: 'istanbul-instrumenter-loader',
          options: { esModules: true },
        },
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
};

Update Karma configuration

karma.conf.js
const testCode = 'src/**/*test.ts';
const webpackConfig = require('./webpack.config.js');
 
module.exports = function (config) {
  config.set({
    autoWatch: false,
    basePath: '',
    browsers: ['Chrome'],
    colors: true,
    concurrency: Infinity,
    coverageIstanbulReporter: {
      fixWebpackSourcePaths: true,
      reports: ['html'],
    },
    exclude: [],
    files: [{ pattern: testCode, watched: false }],
    frameworks: ['jasmine'],
    logLevel: config.LOG_INFO,
    port: 9876,
    preprocessors: {
      [testCode]: ['webpack'],
    },
    reporters: ['progress', 'coverage-istanbul'],
    singleRun: true,
    webpack: webpackConfig,
  });
};

Bonus: TypeScript everything!

Since Webpack 5 you can turn the webpack.config.js into a TypeScript file called webpack.config.ts. If you are using Webpack 4, you will have to add @types/webpack to get type definitions for Webpack's configuration file.

Back to Blog