Aggregating Unit Test Coverage for All Monorepo’s Packages

Matti Bar-Zeev - Dec 31 '21 - - Dev Community

In this post join me as I will be adding an aggregated unit test code coverage report for my Pedalboard monorepo.
Monorepos contain many packages, and for each you (should) have tests for and a way to generate a code coverage report from them, but what if you’d like to have a single place where you can see the overall coverage status of the entire monorepo? Let me show you how…

I start with the basic requirements:

  • I’d like to have a unit test coverage for all the packages under the monorepo
  • I’d like the report to be a easy on the eye, kinda nyc’s, HTML coverage report
  • I’d like it to be easy to generate

It is a good time to mention that my Pedalboard monorepo uses the Jest testing framework. The first step is to add a script at the root project level, with which I can run my test coverage for all the nested packages. Should be straightforward, using Lerna for that. Here is how my scripts look like now:

"scripts": {
       "test": "lerna run test --",
       "coverage": "yarn test --coverage",
       "lint": "lerna run lint",
       "publish:lerna": "lerna publish --yes --no-verify-access"
   },
Enter fullscreen mode Exit fullscreen mode

Notice that I am reusing the yarn test script in my new coverage script. It is also worth mentioning that I’ve added the “--” at the end of the test script so that I won’t need to call it with double “--” to inject any parameters further down to reach the actual script.

Let’s try and run it to see how it goes…
Yeah, it looks good. Coverage report is being created for all the nested packages which have tests to them. Each coverage is generated on the package’s directory:

lerna notice cli v4.0.0
lerna info versioning independent
lerna info Executing command in 3 packages: "yarn run test --coverage"
lerna info run Ran npm script 'test' in '@pedalboard/eslint-plugin-craftsmanlint' in 1.3s:
$ jest --coverage
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 index.js |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
lerna info run Ran npm script 'test' in '@pedalboard/hooks' in 1.6s:
$ jest --coverage
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 index.js |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
lerna info run Ran npm script 'test' in '@pedalboard/components' in 0.9s:
$ jest --coverage
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |                   
----------|---------|----------|---------|---------|-------------------
lerna success run Ran npm script 'test' in 3 packages in 2.5s:
lerna success - @pedalboard/components
lerna success - @pedalboard/eslint-plugin-craftsmanlint
lerna success - @pedalboard/hooks
Done in 2.99s.
Enter fullscreen mode Exit fullscreen mode

(Nobody noticed the rogue one, right? 😅)

So this is great, right? But I would like to have an aggregated report, preferably in a nice web page, how do I do that?

So let’s see if nyc (the code coverage generator) can help with that.
Hmm… this documentation seems interesting! So basically what I understand from it is that I need to collect all the reports from the different packages and then run the nyc report over it.
The flow should be like this:

  1. Create a temp directory where all the coverage json files will be collected in
  2. Run the test coverage on each package
  3. Collect the coverage json files from all packages into that temp directory in the root project
  4. Run an nyc report on that directory
  5. Grab a beer

Ok, first of all let’s install nyc at the root project as a dev dependency:

yarn add -D nyc -W

(the -W is for adding it in the root project in Yarn workspaces)

Although I can take advantage of Lerna in order to run the test coverage command in each package, I still need to collect these files and then run nyc reports on them which is something that Lerna does not support, so I think that it is best that I will create a node.js script which does all that in the a single place. Who knows, since it is a generic script it might be a good candidate for a future scripts package ;)

But let’s start with just having this script on the root project for now. For now the script simply generates and aggregates all the reports into a single directory.
Here is my script:

const fs = require('fs');
const path = require('path');
const {execSync} = require('child_process');

const REPORTS_DIR_NAME = '.nyc_output';
const PACKAGES_DIR_NAME = 'packages';
const PACKAGE_PATH = path.resolve(process.cwd(), PACKAGES_DIR_NAME);
const REPORTS_DIR_PATH = path.resolve(process.cwd(), REPORTS_DIR_NAME);
const BLUE = '\x1b[34m%s\x1b[0m';
const GREEN = '\x1b[32m%s\x1b[0m';

// go over all the packages and produce a coverage report
function aggregateReports() {
    createTempDir();
    generateReports();
}

/**
 * Creates a temp directory for all the reports
 */
function createTempDir() {
    console.log(BLUE, `Creating a temp ${REPORTS_DIR_NAME} directory...`);
    if (!fs.existsSync(REPORTS_DIR_PATH)) {
        fs.mkdirSync(REPORTS_DIR_PATH);
    }
    console.log(GREEN, 'Done!');
}

/**
 * Generate a report for each package and copies it to the temp reports dir
 */
function generateReports() {
    fs.readdir(PACKAGE_PATH, (err, items) => {
        if (err) console.log(err);
        else {
            items.forEach((item) => {
                const itemPath = path.resolve(PACKAGE_PATH, item);
                fs.stat(itemPath, (error, stats) => {
                    if (error) {
                        console.error(error);
                    }
                    // if that item is a directory
                    if (stats.isDirectory()) {
                        // Attempt to launch the coverage command
                        try {
                            console.log(BLUE, `Generating report for the ${item} package...`);
                            execSync('yarn test --coverage --silent', {cwd: itemPath, stdio: 'inherit'});
                            // Copy the generated report to the reports dir
                            const targetFilePath = path.resolve(itemPath, 'coverage', 'coverage-final.json');
                            // check if the report file exists
                            if (fs.existsSync(targetFilePath)) {
                                console.log(BLUE, `Copying the coverage report...`);
                                const destFilePath = path.resolve(REPORTS_DIR_PATH, `${item}.json`);
                                fs.copyFileSync(targetFilePath, destFilePath);
                            }
                        } catch (error) {
                            console.error('Failed to generate reports', error);
                        }
                    }
                });
            });
        }
    });
}

aggregateReports();

Enter fullscreen mode Exit fullscreen mode

I guess it can be made better, with some parallelism and refactoring to it (if you got any suggestions, be sure to leave them in the comments 💪), but this does the work for now :)

My script on the package.json look like this:

"coverage:combined": "node ./scripts/aggregate-packages-coverage.js && nyc report --reporter lcov"
Enter fullscreen mode Exit fullscreen mode

Whenever I call this script all the reports will be generated in each package and then copied to the .nyc_output directory (which is the default dir nyc looks for when attempting to generate reports). And when the copy is done I’m invoking the nyc report command.

In the end there is a “coverage” directory on my root project, which has the aggregated coverage from all my packages:

Image description

Notice that each package has its own coverage json file under the .nyc_output
Here is how the coverage looks like:

Image description

Nice, isn’t it?

Update - you can check out a refactor I've made to this solution, harnessing Yarn workspaces power better: Yarn Workspace Scripts Refactor - A Case Study

And that’s it for now -
I think that all the requirements for this task were well fulfilled - I have an easy way to generate an aggregated coverage report for all the packages under my Monorepo. The report has an HTML format to it, same as you’d have for any single package, which is easy on the eye.
And as always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!
Time for that beer... ;)

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Isaac Smith on Unsplash

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .