Full code-coverage with Jest

A guided tour on how to achieve the most complete code-coverage with Jest in your NodeJS projects and an some thoughts on why this is not necessarily the primary target.

In this article I assume you have a basic understanding of what Jest is and how it is used. So let’s directly jump to it getting our code covered.

Example function to test

In this tour we’ll use the following function as the test object.

// test-functions.js

module.exports.calculateValue = (valueA, valueB, addOffset = false, addWarning = true) => {
  let result = {
    value: (addOffset ? valueA + valueB + 10 : valueA + valueB),
    message: null
  }
  if (result.value > 100) {
    let messageParts = []
    if (addWarning) messageParts.push('Warning:');
    messageParts.push('result is greater then 100');
    result.message = messageParts.join(' ');
  }
  return result;
}

To follow the next steps save this function in test-functions.js in the main folder of your NodeJS project.

Covering your code with Jest

Initial setup and test function touch

To get Jest up & running, install it as a dev dependency in your project.

npm -i jest --save-dev

For ease of use, also in your CI/CD pipelines, I recommend to add script entries for running Jest with and without collecting coverage statistics in your package.json, like so…

...
"scripts": {
  "test": "jest",
  "test-coverage": "jest --coverage"
}
...

Having this in place, you can run the tests in your project by simply calling npm run test or npm run test-coverage (including gathering coverage stats).

Now let’s start writing the test suite for our sample function. We start with an empty suite saved as test/test-functions.test.js which is simply touching the source file…

// test/test-functions.test.js

const { calculateValue } = require('../test-functions');

describe('test-coverage test suite', () => {

  it('tests something', () => {
  });

});

Now, doing a first call to npm run test-coverage will show you that our source file is touched – also it has nearly no coverage yet.

jest-coverage-no-test

You may ask why we are writing this empty test suite at the beginning before showing the coverage stats? The answer is that Jest will – by default – only collect coverage stats for touched source files. Without this empty suite referencing test-functions.js, Jest would report everything green because there’s not even one source file affected, which is not intended here. Please read this great article on how to change that behaviour if you whish or need to in your project.

First straight-forward test

Now let’s write the first simple test in our suite. We’ll call the function to test with the two necessary parameters for valueA and valueB.

it('calculateValue test 1', () => {
  expect(calculateValue(10, 20).value).toBe(30);
});

Having this test in place, the stats are showing we’ve already covered quite some amount of our code…

jest-coverage-test-1

Great, so far. We covered the main part of our function but the test written is very poor. This is, because our function returns an object with two properties but in the test, we only check one if it. Such tests are prone to not-discovering side effects in later changes etc., although they may increase and polish the coverage…

So let’s refactor that being a better test.

it('calculateValue test 1', () => {
  const result = calculateValue(10, 20);
  expect(result.value).toBe(30);
  expect(result.message).toBe(null);
});

Although this refactoring didn’t change anything regarding the code-coverage, I think you will agree that the quality of the test increased a lot. So always remember: code-coverage is not necessarily a synonym for high quality!

Testing uncovered lines

After running our first test, the output of npm run test-coverage shows uncovered lines 9-12. They are marked red, because they are completely untouched by any test.

These lines are within an if-statement that is only entered when the passed two values together will be greater than 100. So let’s go ahead and write another simple test satisfying this condition.

it('calculateValue test 2', () => {
  const result = calculateValue(100, 20);
  expect(result.value).toBe(120);
  expect(result.message).toBe('Warning: result is greater then 100');
});

Having this second test in place, the stats are now showing a 100% coverage of all lines and statements.

jest-coverage-test-2

However, there are still uncovered lines (5 and 10) left marked yellow. So let’s go on in covering these.

Testing uncovered branches

The uncovered lines marked in yellow are part of code branches (if-else statements, ternary operators or else). Corrsponding to that you can see that our branch coverage is at 75% and therefore not complete yet.

So let’s start in inspecting first line, which is number 5:

value: (addOffset ? valueA + valueB + 10 : valueA + valueB),

Can you see what is not covered here by any test? Now, it might be a bit tricky – even more than in our example – to figure out what is not covered. Fortunately, Jest provides a great tool for further coverage inspection: a HTML report.

When running Jest with the --coverage option (like in our npm run test-coverage), it generates a report under coverage/lcov-report/index.html. Simply open this site iny our browser and navigate to our source file’s sub-page.

jest-coverage-html-report

Here – again marked yellow – you can very easily see, what part of line 5 is not covered by any test. It is the first return path of the ternary operator which would be executed if addOffset is true. Hovering the mouse over it will show you ‘branch not covered’.

So let’s write another test covering this code branch.

it('calculateValue test 3', () => {
  const result = calculateValue(100, 20, true);
  expect(result.value).toBe(130);
  expect(result.message).toBe('Warning: result is greater then 100');
});

Great! Line 5 is now fully covered.

jest-coverage-test-3

Nevertheless, line 10 is still left marked uncovered also the generated HTML report doesn’t show anything there?!

Testing non-existent else-paths

To solve this mystery, let’s investigate line 10:

if (addWarning) messageParts.push('Warning:');

Obviously, the condition for the if-statement is true for all of our existing tests as addWarning is true by default in the test function and we never did override that. Therefore, this line is already covered, BUT: there is an implicit else-branch not written explicitly in the code which is never taken. This is when addWarning is false. Then the code in the if-statement will not be executed which may affect the logic of our function and therefore is subject to test for Jest by default.

So let’s write another test covering the implicit else-branch of our test function.

it('calculateValue test 4', () => {
  const result = calculateValue(100, 20, false, false);
  expect(result.value).toBe(120);
  expect(result.message).toBe('result is greater then 100');
});

Awesome! We have now covered our test function to 100% in all aspects: statements, lines and also branches.

Excluding untestable code-sections from the coverage stats

Despite all attempts you might end up in not finding a feasible or “affordable” test case for every line of code. To keep your coverage stats clean and meaningful anyways, Jest provides a way to explicitly exclude code sections from the coverage stats. For that, please refer to the article on ignoring code for coverage.

Your primary goal should always be to cover a line/branch with an appropriate test. But not at any price.

Summary and further steps

Putting it all together, our final test suite for a full code coverage looks like this. Note that the empty initial test case from the setup is removed as this was only needed to do the initial run of the Jest suite (a suite must contain at least one test).

// test/test-functions.test.js

const { calculateValue } = require('../test-functions');

describe('test-coverage test suite', () => {

  it('calculateValue test 1', () => {
    const result = calculateValue(10, 20);
    expect(result.value).toBe(30);
    expect(result.message).toBe(null);
  });

  it('calculateValue test 2', () => {
    const result = calculateValue(100, 20);
    expect(result.value).toBe(120);
    expect(result.message).toBe('Warning: result is greater then 100');
  });

  it('calculateValue test 3', () => {
    const result = calculateValue(100, 20, true);
    expect(result.value).toBe(130);
    expect(result.message).toBe('Warning: result is greater then 100');
  });

  it('calculateValue test 4', () => {
    const result = calculateValue(100, 20, false, false);
    expect(result.value).toBe(120);
    expect(result.message).toBe('result is greater then 100');
  });

});

Where to go now? As the next step it is highly recommend to automate running the tests in your development process.

One very easy and convenient way to do so is GitHub actions. See my article on setting up a simple CI/CD pipeline with GitHub actions. There it is also explained how to include Coveralls for monitoring coverage stats.

100% code-coverage – worth the effort?

You may now ask yourself if it is always the ultimate target to reach 100% coverage? The answer to that is not easy and depending on many parameters of your project as you may have guessed 😉

In general, it is always a good approach trying to cover everything by following a pragmatic and affordable plan: writing straight forward test-cases, trying to get uncovered and non-existing branches. Normally, you should achieve quite high coverages of 80-90% or even more with that in a reasonable time.

Before you start investing another big amount of work for covering the rest up to 100%, keep in mind that…

  • As always in projects, the “Last Ten Percent” rule will kick in. It is very likely that you’ll spend more time in covering the last 10% then you did for the first 90%.
  • There are limitations by the test framework itself: Although Jest is a very popular and great framework, it is not fully free of technical errors. These might be preventing you to test properly or even produce false-negatives. At the time of writing this article Jest 27.4.5 was the latest version which had ~1.500 issues and 200 pull requests logged at npmjs.com.
  • You might have technically complicated edge-cases where you will spend more time in finding out how to implement the test itself rather then caring about the test logic. One example for that could be testing a process exit in a CLI project.

When reaching such points, you should always ask yourself: Is it beneficial for my project to spend more time here?

As a rule of thumb, I would recommend trying to reach an acceptable level (e.g. >= 80%) of code coverage by executing a pragmatic and straight-forward test implementation plan. Doing that, focus on complete and high-quality test cases where all pre- and post-conditions are checked to avoid side effects, especially for later changes. That’s more important then having full coverage. Then go ahead and don’t spend more time in implementing tests just for polishing coverage stats. Unless your employer or customer demand more or time & budget are unlimited in your project 😉

Useful links