, ,

How to Generate Unified Coverage Reports in an Nx Workspace

Nov 17, 2024 reading time 10 minutes

How to Generate Unified Coverage Reports in an Nx Workspace

In This Blog Post, I’ll show how to create a unified test coverage report for an Nx workspace. While Nx generates individual coverage reports for each project, it lacks a built-in solution for a complete workspace view. This blog post provides a simple tool to merge these reports and generate detailed formats like HTML, Cobertura, JSON and more.

How to Generate Unified Coverage Reports in an Nx Workspace

Running the tests for the complete workspace

You can use Nx’s run-many command to let the tests run for many projects in your workspace, not just for a single one. With the --code-coverage flag, we can also let create the coverage for all the projects individually.

{
  "scripts": {
    "test": "nx run-many -t test --codeCoverage",
    // ...
  },
}

After letting this command run, we receive a coverage folder with all our projects in a specific folder.

coverage/
├── apps/
│   └── doggo-rating-app/
└── libs/
    ├── about/
    │   └── feature/
    ├── doggos/
    │   ├── domain/
    │   ├── feature/
    │   └── ui/
    └── shared/
        ├── ui-common/
        ├── util-auth/
        ├── util-camera/
        ├── util-common/
        ├── util-environments/
        ├── util-notification/
        ├── util-platform-information/
        └── util-real-time/

The problem now is that we do not have an overview of our complete workspace coverage. Only the individual projects separately.

But we can write a little bit of code to create a report which is valuable for the complete workspace with all the reporters we want to have.

Writing a helper tool

We can create a tool in Nx tools folder called create-overall-coverage.js . You can find the complete file in the link section below.

tools/
├── create-overall-coverage.js
└── tsconfig.tools.json

Then we have to install the dependencies we need. For this, we only need fs-extra and Istanbul’s CLI which is called “nyc“.

Nyc can read a folder with json files and create a full report from this in the specific format we have to target. Nx provides us the json report for each project inside the specific folder. So the steps to run are

  1. Find all the json coverage files for each project. They are called coverage-final.json in each project folder.
  2. Rename the file to a project specific identifier.
  3. Copy that renamed file into a .temp folder.
  4. Let nyc merge that json files to a .complete folder holding our complete report in each format we want.

Let us start implementing the logic. First, we can define the paths we want to work with.

Our source path is a path called coverage, that is the path which was created by Nx/jest which holds all the project folders. Then we have a temp folder, a target folder, the filename of the individual coverage json report and a file where the complete report should be generated to.

const TEST_RESULTS_FOLDER = 'coverage';
const TEMP_FOLDER = 'coverage/.temp';
const COVERAGE_FOLDER = 'coverage/.complete';
const FINAL_JSON_COVERAGE_FILENAME = 'coverage-final.json';
const MERGED_COVERAGE_FILENAME = 'coverage-complete.json';

Then we can implement two methods. The first one is merging all the coverage json files to a complete json in the target folder .complete . The second method is running nyc to create all the desired reports out of this complete json report.

const fs = require('fs-extra');
const path = require('path');
const { exec } = require('child_process');

const TEST_RESULTS_FOLDER = 'coverage';
const TEMP_FOLDER = 'coverage/.temp';
const COVERAGE_FOLDER = 'coverage/.complete';
const FINAL_JSON_COVERAGE_FILENAME = 'coverage-final.json';
const MERGED_COVERAGE_FILENAME = 'coverage-complete.json';

function mergeCoverageFilesToJson() {
  // ...
}

function createIstanbulReportFromJson() {
  // ...
}

const main = function () {
  mergeCoverageFilesToJson();
  createIstanbulReportFromJson();
};

main();

First, we are going to clean the temp folder before each run to have no old results left.

function mergeCoverageFilesToJson() {
  clearTempFolder();
  // ...
}

function clearTempFolder() {
  fs.emptyDirSync(TEMP_FOLDER);
}

Then we have to find all the coverage-final.json files jest created for us with it’s corresponding path.

function mergeCoverageFilesToJson() {
  clearTempFolder();

  const jsonFiles = findCoverageFiles(TEST_RESULTS_FOLDER);
  // ...
}

function clearTempFolder() {
  fs.emptyDirSync(TEMP_FOLDER);
}

function findCoverageFiles(dir, files = []) {
  const entries = fs.readdirSync(dir);

  for (const entry of entries) {
    const entryPath = path.join(dir, entry);

    if (fs.statSync(entryPath).isDirectory()) {
      findCoverageFiles(entryPath, files);
    } else if (path.basename(entryPath) === FINAL_JSON_COVERAGE_FILENAME) {
      files.push(entryPath);
    }
  }

  return files;
}

Then we can loop over the paths and copy the files into the .temp directory. The problem here is that they are all named the name. So we would overwrite the file on each copy. So we have to make sure that the project name is in the json file.

coverage\libs\about\feature\coverage-final.json --> about-feature-coverage-final.json
coverage\apps\doggo-rating-app\coverage-final.json --> apps-doggo-rating-app-coverage-final.json

To achieve this, we can implement a function like this:

function mergeCoverageFilesToJson() {
  clearTempFolder();

  const jsonFiles = findCoverageFiles(TEST_RESULTS_FOLDER);

  console.log(`Found ${jsonFiles.length} coverage JSON files.`);

  for (const filePath of jsonFiles) {
    const targetPath = getTargetPath(filePath);
    console.log(`Copying '${filePath}' to '${targetPath}'`);
    fs.copySync(filePath, targetPath);
  }
}

function findCoverageFiles(dir, files = []) {
  // ...
}

function getTargetPath(filePath) {
  // Separating the path and getting the last two entries of the path
  // 'coverage\libs\about\feature\coverage-final.json' --> about-feature
  // 'coverage\libs\shared\util-environments\coverage-final.json' --> shared-util-environments
  const projectName = path
    .dirname(filePath)
    .split(path.sep)
    .slice(-2)
    .join('-');

  // adding the `coverage-final.json` back to the end
  const filename = `${projectName}-${path.basename(filePath)}`;

  // targeting the temp folder and return
  return path.join(TEMP_FOLDER, filename);
}

Now we have copied all the files over to the temp folder.

.temp/
├── about-feature-coverage-final.json
├── apps-doggo-rating-app-coverage-final.json
├── doggos-domain-coverage-final.json
├── doggos-feature-coverage-final.json
├── doggos-ui-coverage-final.json
├── shared-ui-common-coverage-final.json
├── shared-util-auth-coverage-final.json
├── shared-util-camera-coverage-final.json
├── shared-util-common-coverage-final.json
├── shared-util-environments-coverage-final.json
├── shared-util-notification-coverage-final.json
├── shared-util-platform-information-coverage-final.json
└── shared-util-real-time-coverage-final.json

We have to tell nyc now to create a complete json report from this temporary folder in the final folder .complete.

I implemented a method called executeCommand where I can pass a command in which is being executed respecting the stderr and stdout that I can see everything on the console when I execute this file.

function executeCommand(command) {
  exec(command, (err, stdout, stderr) => {
    if (err) {
      console.error(`Error executing command: ${command}`, err);

      return;
    }
    if (stdout) {
      console.log(stdout);
    }
    if (stderr) {
      console.error(stderr);
    }
  });
}

In my file I can now create a complete report from all the json files like this:

function mergeCoverageFilesToJson() {
  clearTempFolder();

  const jsonFiles = findCoverageFiles(TEST_RESULTS_FOLDER);

  console.log(`Found ${jsonFiles.length} coverage JSON files.`);

  for (const filePath of jsonFiles) {
    const targetPath = getTargetPath(filePath);
    console.log(`Copying '${filePath}' to '${targetPath}'`);
    fs.copySync(filePath, targetPath);
  }

  const mergedCoveragePath = path.join(
    COVERAGE_FOLDER,
    MERGED_COVERAGE_FILENAME
  );

  executeCommand(`nyc merge ${TEMP_FOLDER} ${mergedCoveragePath}`);
}

The output is the final json file inside the .complete folder

coverage/
├── apps/
├── .complete/
│   └── coverage-complete.json // <-- just got created
├── libs/
├── .temp/
    ├── about-feature-coverage-final.json
    ├── apps-doggo-rating-app-coverage-final.json
    ├── doggos-domain-coverage-final.json
    ├── doggos-feature-coverage-final.json
    ├── doggos-ui-coverage-final.json
    ├── shared-ui-common-coverage-final.json
    ├── shared-util-auth-coverage-final.json
    ├── shared-util-camera-coverage-final.json
    ├── shared-util-common-coverage-final.json
    ├── shared-util-environments-coverage-final.json
    ├── shared-util-notification-coverage-final.json
    ├── shared-util-platform-information-coverage-final.json
    └── shared-util-real-time-coverage-final.json

Basically, we have the full report now generated in the .complete/coverage-complete.json . But we want it to be also available in HTML and all the other formats we can see something or provide it to Azure Pipelines, GitHub Pipelines, SonarCube etc.

A method called createIstanbulReportFromJson is reading the temp folder with all the JSONs inside and create an HTML report, a cobertura report and a text summary report.

function createIstanbulReportFromJson() {
  const command = `nyc report -t ${TEMP_FOLDER} --report-dir ${COVERAGE_FOLDER} --reporter=html --reporter=cobertura --reporter=lcov --reporter=text-summary`;

  executeCommand(command);

  console.log(`Executed '${command}' successfully.`);
}

And that is basically our file!

We now have to add a script in the package.json to call that file

{
  "scripts": {
    "test": "nx run-many -t test --codeCoverage",
    "summarize:coverage": "node tools/create-overall-coverage.js"
    // ...
  },
}

Putting it all together

const fs = require('fs-extra');
const path = require('path');
const { exec } = require('child_process');

const TEST_RESULTS_FOLDER = 'coverage';
const TEMP_FOLDER = 'coverage/.temp';
const COVERAGE_FOLDER = 'coverage/.complete';
const FINAL_JSON_COVERAGE_FILENAME = 'coverage-final.json';
const MERGED_COVERAGE_FILENAME = 'coverage-complete.json';

function mergeCoverageFilesToJson() {
  clearTempFolder();

  const jsonFiles = findCoverageFiles(TEST_RESULTS_FOLDER);

  console.log(`Found ${jsonFiles.length} coverage JSON files.`);

  for (const filePath of jsonFiles) {
    const targetPath = getTargetPath(filePath);
    console.log(`Copying '${filePath}' to '${targetPath}'`);
    fs.copySync(filePath, targetPath);
  }

  const mergedCoveragePath = path.join(
    COVERAGE_FOLDER,
    MERGED_COVERAGE_FILENAME
  );

  executeCommand(`nyc merge ${TEMP_FOLDER} ${mergedCoveragePath}`);
}

function createIstanbulReportFromJson() {
  const command = `nyc report -t ${TEMP_FOLDER} --report-dir ${COVERAGE_FOLDER} --reporter=html --reporter=cobertura --reporter=lcov --reporter=text-summary`;

  executeCommand(command);

  console.log(`Executed '${command}' successfully.`);
}

function findCoverageFiles(dir, files = []) {
  const entries = fs.readdirSync(dir);

  for (const entry of entries) {
    const entryPath = path.join(dir, entry);

    if (fs.statSync(entryPath).isDirectory()) {
      findCoverageFiles(entryPath, files);
    } else if (path.basename(entryPath) === FINAL_JSON_COVERAGE_FILENAME) {
      files.push(entryPath);
    }
  }

  return files;
}

function getTargetPath(filePath) {
  // Separating the path and getting the last two entries of the path
  // 'coverage\libs\about\feature\coverage-final.json' --> about-feature
  // 'coverage\libs\shared\util-environments\coverage-final.json' --> shared-util-environments
  const projectName = path
    .dirname(filePath)
    .split(path.sep)
    .slice(-2)
    .join('-');

  // adding the `coverage-final.json` back to the end
  const filename = `${projectName}-${path.basename(filePath)}`;

  // targeting the temp folder and return
  return path.join(TEMP_FOLDER, filename);
}

function clearTempFolder() {
  fs.emptyDirSync(TEMP_FOLDER);
}

function executeCommand(command) {
  exec(command, (err, stdout, stderr) => {
    if (err) {
      console.error(`Error executing command: ${command}`, err);

      return;
    }
    if (stdout) {
      console.log(stdout);
    }
    if (stderr) {
      console.error(stderr);
    }
  });
}

const main = function () {
  mergeCoverageFilesToJson();
  createIstanbulReportFromJson();
};

main();

Links

https://github.com/FabianGosebrink/doggo-rate-app/blob/main/angular/tools/create-overall-coverage.js

https://www.npmjs.com/package/fs-extra

https://www.npmjs.com/package/nyc