Code coverage is a metric that measures what part of the tested code was executed. It's relevant because it helps to find edge cases, untested or dead code. Especially, when the application grows bigger and so does the code, these metrics become very useful in finding untested parts.

To execute tests with code coverage simply run:

npx jest --coverage

The Jest will display a summary table in the shell. By default, the HTML report will be generated in the ./coverage directory. Navigate to ./coverage/lcov-report and open index.html in the browser to view it.

For all available options and configurations refer to the official Jest documentation jestjs.io.

Requirements

  • NodeJS v16

  • NPM v8

  • npx

Setup

Install dependencies:

npm i jest@29 @types/jest@29 ts-jest@29 typescript@4.7

Initialize the default ts-jest configuration.

npx ts-jest config:init

Example

Assume, that you have two files:

  • functions.ts

  • functions.spec.ts

Take a look at the following code. It's supposed to sum all the passed numbers and return a result.

// functions.ts

export const sum = (values: number[]): number  => {
  return values.reduce((previous, current) => previous + current, 0);
}

The test file:

// functions.spec.ts

import { sum } from './functions';


interface Dataset {
  input: number[];
  result: number;
}

const datasets: Dataset[] = [
  {input: [0], result: 0},
  {input: [0, 1], result: 1},
  {input: [2, 6], result: 8},
  {input: [-9, 0], result: -9},
  {input: [-9, -2], result: -11},
  {input: [-9, 100], result: 91},
]

describe('Test sum function', () => {
  it.each(datasets)('dataset: %j', (dataset: Dataset) => {
    const result = sum(...dataset.input);
    expect(result).toBe(dataset.result);
  });
});

Run npx jest --coverage. All tests passed and the coverage shows 100%.

--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------|---------|----------|---------|---------|-------------------
All files     |     100 |      100 |     100 |     100 |                   
 functions.ts |     100 |      100 |     100 |     100 |                   
--------------|---------|----------|---------|---------|-------------------

Next, update the definition of the sum function by introducing argument validation.

// functions.ts

export const sum = (values: number[]): number | never => {
  values.forEach((index, value) => {
    if (!Number.isFinite(value)) {
      throw new Error(`Provided argument at index:${index}, value:${String(value)} is invalid`);
    }
  });

  const result = values.reduce((previous, current) => previous + current, 0);

  if (!Number.isFinite(result)) {
    throw new Error('The result exceeded supported numeric values');
  }

  return result;
}

Run npx jest --coverage. All tests passed, but not all code paths were tested. A quick glimpse at the report shows, that lines 4 and 11 were not executed.

--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------|---------|----------|---------|---------|-------------------
All files     |      80 |        0 |     100 |      75 |                   
 functions.ts |      80 |        0 |     100 |      75 | 4,11              
--------------|---------|----------|---------|---------|-------------------

To test the new code paths create separate tests that cause the exceptions. There are a couple of new cases to cover:

  • pass null, undefined, NaN, Infinity or -Infinity

  • make the sum of arguments resulting in Infinity or -Infinity

import { sum } from './functions';


interface Dataset {
  input: number[];
  result: number;
}

const datasets: Dataset[] = [
  {input: [0], result: 0},
  {input: [0, 1], result: 1},
  {input: [2, 6], result: 8},
  {input: [-9, 0], result: -9},
  {input: [-9, -2], result: -11},
  {input: [-9, 100], result: 91},
];

function formatMessage(index: number, value: number) {
  return `Invalid argument: index:${index}, value:${String(value)}`;
}

interface ExceptionDataset {
  input: number[],
  errorMessage: string;
}

const exceptionDatasets: ExceptionDataset[] = [
  {
    input: [null],
    errorMessage: formatMessage(0, null)
  },
  {
    input: [undefined],
    errorMessage: formatMessage(0, undefined)
  },
  {
    input: [NaN],
    errorMessage: formatMessage(0, NaN)
  },
  {
    input: [Infinity],
    errorMessage: formatMessage(0, Infinity)
  },
  {
    input: [-Infinity],
    errorMessage: formatMessage(0, -Infinity)
  },
  {
    input: [0, 0, 0, NaN],
    errorMessage: formatMessage(3, NaN)
  },
  {
    input: [1, null, 2],
    errorMessage: formatMessage(1, null)
  },
  {
    input: [1, null, 2, undefined, 3, NaN],
    errorMessage: formatMessage(1, null)
  },
  {
    input: [Math.pow(2, 1023), Math.pow(2, 1023)],
    errorMessage: 'The result exceeded supported numeric values'
  },
  {
    input: [-Math.pow(2, 1023), -Math.pow(2, 1023)],
    errorMessage: 'The result exceeded supported numeric values'
  },
];

describe('Test sum function', () => {
  it.each(datasets)('dataset: %j', (dataset: Dataset) => {
    const result = sum(dataset.input);
    expect(result).toBe(dataset.result);
  });

  it.each(exceptionDatasets)('dataset: %j', (dataset: ExceptionDataset) => {
    expect(() => sum(dataset.input)).toThrow(dataset.errorMessage);
  });
});

Run the tests with coverage and now all the paths are tested.

  Test sum function
    ✓ dataset: {"input":[0],"result":0}
    ✓ dataset: {"input":[0,1],"result":1}
    ✓ dataset: {"input":[2,6],"result":8}
    ✓ dataset: {"input":[-9,0],"result":-9}
    ✓ dataset: {"input":[-9,-2],"result":-11}
    ✓ dataset: {"input":[-9,100],"result":91}
    ✓ dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:null"} 
    ✓ dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:undefined"}
    ✓ dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:NaN"}
    ✓ dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:Infinity"} 
    ✓ dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:-Infinity"} 
    ✓ dataset: {"input":[0,0,0,null],"errorMessage":"Invalid argument: index:3, value:NaN"}
    ✓ dataset: {"input":[1,null,2],"errorMessage":"Invalid argument: index:1, value:null"}
    ✓ dataset: {"input":[1,null,2,null,3,null],"errorMessage":"Invalid argument: index:1, value:null"} (
    ✓ dataset: {"input":[8.98846567431158e+307,8.98846567431158e+307],"errorMessage":"The result exceeded supported numeric values"} 
    ✓ dataset: {"input":[-8.98846567431158e+307,-8.98846567431158e+307],"errorMessage":"The result exceeded supported numeric values"} 

--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------|---------|----------|---------|---------|-------------------
All files     |     100 |      100 |     100 |     100 |                   
 functions.ts |     100 |      100 |     100 |     100 |                   
--------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       16 passed, 16 total

A quick note about %j formatting. Due to the fact, that the value is converted to JSON we lose some details. Since the JSON format doesn't support values like NaN, undefined or Infinity they are all converted to null. Take a look at the message:

dataset: {"input":[null],"errorMessage":"Invalid argument: index:0, value:undefined"}

The input is [null] but the value in the error message is undefined. So the input's [null] was originally [undefined] but the information was lost during the conversion. In the message, you can see index:0, value:undefined" because that message was generated manually while the dataset was built. Since it's sufficient for this article, you should consider using a different approach to formatting when writing your tests. This might save you some headaches in the future.

Summary

In this article, I've shown you how to use code coverage and how can it be useful. When your application and the team grow bigger, and the code becomes more complex, the usage of code coverage becomes the natural part of the testing process.