JavaScript Testing

Interpreting the code coverage metric

Marcin Wanago
JavaScriptTesting

With code coverage, we can measure the percentage of our code that runs with our test. In this article, we learn how to measure our coverage and discuss whether it is a metric worth considering. We will understand its benefits and limitations and see how a high code coverage can mislead us into thinking that we tested our code thoroughly. Thanks to that, we will be better equipped to use code coverage effectively as part of our testing process.

Measuring the code coverage

Let’s create a straightforward function that will help us illustrate how the code coverage is measured.

getAgeGroup.ts
1export enum AgeGroup {
2  Child = 'Child',
3  Teenager = 'Teenager',
4  Adult = 'Adult',
5}
6 
7function validateAge(age: number) {
8  if (age < 0) {
9    throw new Error('The age can not be negative');
10  }
11}
12 
13export function getAgeGroup(age: number) {
14  validateAge(age);
15  if (age < 13) {
16    return AgeGroup.Child;
17  }
18  if (age < 18) {
19    return AgeGroup.Teenager;
20  }
21  return AgeGroup.Adult;
22}

Above, we return a different age group based on the provided number. If the number is negative, we throw an error.

Let’s write a simple test using Jest.

getAgeGroup.test.ts
1import { AgeGroup, getAgeGroup } from './getAgeGroup';
2 
3describe('When the getAgeGroup function is called', () => {
4  describe('and a number smaller than 13 is provided', () => {
5    it('should return the Child age group', () => {
6      const result = getAgeGroup(10);
7      expect(result).toBe(AgeGroup.Child);
8    });
9  });
10});

Our test ensures that if we provide a number smaller than 13, the function returns the child age group.

PASS src/getAgeGroup.test.ts When the getAgeGroup function is called and a number smaller than 13 is provided ✓ should return the Child age group

Pointing to the correct files

We should tell Jest which files to collect coverage from using the collectCoverageFrom property.

jest.config.ts
1import type { Config } from 'jest';
2 
3const config: Config = {
4  testRegex: '.*.test.ts$',
5  preset: 'ts-jest',
6  collectCoverageFrom: [
7    './src/**/*.ts'
8  ],
9};
10 
11export default config;

If we don’t do that, Jest will collect the coverage only from the tested files. If we had a file with no tests, it would not have affected the coverage number. We should avoid that.

Running the correct command

To measure the code coverage, we must run Jest with the --coverage flag.

package.json
1{
2  "scripts": {
3    "start": "ts-node index.ts",
4    "lint": "eslint . --ext .ts",
5    "prettier": "prettier --write \"src/**/*.ts\"",
6    "test": "jest",
7    "test:coverage": "jest --coverage"
8  },
9  ...
10}

Let’s run the test:coverage command and inspect the output.

1----------------|---------|----------|---------|---------|-------------------
2File            | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
3----------------|---------|----------|---------|---------|-------------------
4All files       |   69.23 |       60 |     100 |   69.23 |                   
5 getAgeGroup.ts |   69.23 |       60 |     100 |   69.23 | 9,18-21           
6----------------|---------|----------|---------|---------|-------------------

Explaining each coverage metric

In the output above, we can see various coverage metrics. Let’s go through all of them.

Statement coverage

Our test has a 69.23 percent statement coverage. It means that 30.77 percent of executable statements remain untested, potentially hiding some bugs.

An executable statement is a statement that performs an action, such as assigning a value to a variable or calling a function.

Branch coverage

In our code, there are places where decisions are made, for example, with if statements.

1if (age < 18) {
2  return AgeGroup.Teenager;
3}

Our code is branching out. Only in some cases does it reach the return AgeGroup.Teenager part. Branch coverage measures the percentage of executed branches such as the one above. Since our branch coverage is 60 percent, 40 percent of branches are not covered by our test.

Function coverage

Function coverage determines the percentage of functions called during testing. It’s important to notice that even if we don’t test the validateAge function explicitly, we call it in the getAgeGroup function. Thanks to that, our function coverage is 100 percent. However, that does not mean that each function is thoroughly tested. This metric simply shows us that all functions in a particular file have been called.

Line coverage

The line coverage shows the number of lines that have been executed. Our line coverage is 69.23 percent, meaning 30.77 percent of lines are untested.

This metric is very similar to the statement coverage metric. In our code, we put one statement per line, so those metrics are identical in our case. However, it could be different if we would put more than one statement per line, such as the following:

1const age = 20; console.log(age);

Uncovered lines

This part of the coverage output tells us which lines of code are not covered by the tests. This makes it easier to pinpoint the exact places we should focus on.

Improving our test to increase the coverage

Let’s modify our test suite to cover all of the possible cases.

getAgeGroup.test.ts
1import { AgeGroup, getAgeGroup } from './getAgeGroup';
2 
3describe('When the getAgeGroup function is called', () => {
4  describe('and a negative number is provided', () => {
5    it('should throw an error', () => {
6      expect(() => getAgeGroup(-1)).toThrow();
7    });
8  });
9  describe('and a number smaller than 13 is provided', () => {
10    it('should return the Child age group', () => {
11      const result = getAgeGroup(10);
12      expect(result).toBe(AgeGroup.Child);
13    });
14  });
15  describe('when a number between 13 and 18 is provided', () => {
16    it('should return the Teenager age group', () => {
17      const result = getAgeGroup(15);
18      expect(result).toBe(AgeGroup.Teenager);
19    });
20  });
21  describe('when a number bigger than 18 is provided', () => {
22    it('should return the Adult age group', () => {
23      const result = getAgeGroup(20);
24      expect(result).toBe(AgeGroup.Adult);
25    });
26  });
27});
PASS src/getAgeGroup.test.ts When the getAgeGroup function is called and a negative number is provided ✓ should throw an error and a number smaller than 13 is provided ✓ should return the Child age group when a number between 13 and 18 is provided ✓ should return the Teenager age group when a number bigger than 18 is provided ✓ should return the Adult age group

Our code coverage is now 100% across all metrics, thanks to covering all cases.

1----------------|---------|----------|---------|---------|-------------------
2File            | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
3----------------|---------|----------|---------|---------|-------------------
4All files       |     100 |      100 |     100 |     100 |                   
5 getAgeGroup.ts |     100 |      100 |     100 |     100 |                   
6----------------|---------|----------|---------|---------|-------------------

The flaws of code coverage metrics

Low code coverage can be a good indicator that our tests are lacking. However, even 100 percent coverage does not guarantee a function is thoroughly tested.

getUserAgeFromJson.ts
1export function getUserAgeFromJson(json: string) {
2  const parsedJson = JSON.parse(json);
3  return parsedJson.age;
4}

We only need a straightforward test to achieve 100 percent coverage for the above function.

getUserAgeFromJson.test.ts
1import { getUserAgeFromJson } from './getUserAgeFromJson';
2 
3describe('The parseUserJson function', () => {
4  describe('when provided with a string containing a JSON representing a user', () => {
5    it('should return their age', () => {
6      const result = getUserAgeFromJson('{ "age": 25 }');
7      expect(result).toBe(25);
8    });
9  });
10});

Our test achieves full code coverage, but we are far from testing every case. For example, if we provide a string that is not a valid JSON, our function throws an error. If we provide a valid JSON that does not contain the age property, our function returns undefined.

Summary

The code coverage is a good way to track how much of our code is executed by our tests. However, it does not tell us much about the quality of our tests. Even if we have the full code coverage, our tests might still miss important cases that we should cover.

Striving to have high code coverage can benefit our application, but it does not yet mean our code is bug-free. Therefore, we must also focus on creating meaningful tests that challenge our code in various scenarios to ensure the reliability of our codebase.