, ,

Nx and Azure DevOps Integration for Test and Coverage Reporting

Nov 05, 2023 reading time 9 minutes

Nx and Azure DevOps Integration for Test and Coverage Reporting

In this blog post, I want to describe how you can seamlessly integrate test coverage reporting into your Nx Monorepo workflow using Azure DevOps, providing a clear and actionable view of your code quality.

Nx and Azure DevOps Integration for Test and Coverage Reporting

Introduction

In today’s software development landscape, it’s crucial to maintain high code quality and ensure that your applications are robust and almost bug-free 😉. One essential aspect of achieving this goal is by writing comprehensive unit and integration tests and continuously monitoring your test coverage. But, when you’re working with complex projects like Nx Monorepos, tracking and presenting test coverage can become a challenging task.

Nx Monorepos are a powerful approach to managing large-scale codebases, enabling teams to efficiently develop, build, and test multiple applications and libraries within a single code repository. However, when it comes to visualizing and communicating test coverage in a monorepo, things can get a bit tricky. As an application as a whole is described in multiple projects we have to find a way of summarizing all the reports, having the correct formats and placing them into our pipelines, to have a reliable source and representation of the test code insights.

In this blog post, we will explore the integration of Nx Monorepos with Azure DevOps to track and display test coverage on Pull Requests (PRs). We will see how we can provide our development teams with insights into their code quality, enabling them to catch and fix issues before they make it to production.



Publishing test results with Azure DevOps

I assume you have some pipelines, it may be GitHub Actions or an Azure DevOps Pipeline, which could look like this

trigger: 
  # ...

variables:
    nodeVersion: 16.14.0

pool:
  vmImage: ubuntu-latest

jobs:

  - job: Build
    displayName: Build

    steps:
      # steps for building

  - job: Lint
    displayName: Lint

    steps:
      # steps for linting

  - job: Test
    displayName: Test

    steps:
      - task: NodeTool@0
        inputs:
          versionSpec: $(nodeVersion)
        displayName: Install Node.js

      - task: Npm@1
        displayName: Install Dependencies
        inputs:
          command: custom
          verbose: false
          customCommand: ci

      - task: Npm@1
        displayName: Run testing (affected)
        inputs:
          command: custom
          verbose: true
          customCommand: 'run test:affected'

Further we have a package.json which wraps our nx commands

{
  "name": "my-private-repo",
  "scripts": {
    "ng": "nx",
    // .. remove for brevity
    "test:affected": "nx affected --target=test --base=origin/main --coverage"
  },
  "private": true,
  "dependencies": {
    // ...
  },
  "devDependencies": {
    // ...
  },
}

The test:affected command wraps the command for nx testing the affected projects in this PR and adding the coverage as well nx affected --target=test --base=origin/main --coverage

Nx is now creating a coverage folder with all the file and folder structure you have it in your repository.

However, Azure DevOps needs _one_ file where all the test results are for, and we can tell jest in the Nx monorepo to output all the test files flat into a test folder, for example test_results.

For this, enter the jest.preset.js file and add the reporters property like the following

const nxPreset = require('@nx/jest/preset').default;

module.exports = {
  ...nxPreset,
  // ... other properties
  reporters: [
    'default',
    [
      'jest-junit',
      {
        outputDirectory: `${process.env.NX_WORKSPACE_ROOT}/test_results`,
        outputName: `${process.env['NX_TASK_TARGET_PROJECT']}.junit.xml`,
      },
    ],
  ],
};

Normally per run for each app/lib jest would create a junit.xml file overwriting the previous one, but we have to make sure that for each lib and app a separate file gets created. With this configuration, we create a test_results folder with all the <app-or-lib-name.junit.xml” file in it.

For merging the files together, we can use an npm package called junit-merge.

We can extend our package.json with another command to merge all the files.

{
  "name": "my-private-repo",
  "scripts": {
    "ng": "nx",
    // .. remove for brevity
    "test:affected": "nx affected --target=test --base=origin/main --coverage",
    "merge-junit": "junit-merge --dir=./test_results --out=./test_results/complete-junit.xml"
  },
  "private": true,
  "dependencies": {
    // ...
  },
  "devDependencies": {
    // ...
  },
}

The output of this command is a file in test_results/complete-junit.xml which we, in the next step, can provide the Azure DevOps Pipeline. But first, let us extend our pipeline to call this command:

trigger: 
  # ...

variables:
    nodeVersion: 16.14.0

pool:
  vmImage: ubuntu-latest

jobs:

  - job: Build
    displayName: Build

    steps:
      # steps for building

  - job: Lint
    displayName: Lint

    steps:
      # steps for linting

  - job: Test
    displayName: Test

    steps:
      # ... all tasks as above

      # ADD THIS STEP
      - task: Npm@1
        displayName: Summarizing affected test files
        continueOnError: true
        inputs:
          command: custom
          verbose: true
          customCommand: 'run merge-junit'

When this is done we can provide the results to the pipeline with the corresponding task

      - task: PublishTestResults@2
        displayName: Publishing Test Results to Pipeline
        continueOnError: true
        inputs:
          testResultsFormat: 'JUnit'
          testResultsFiles: 'test_results/complete-junit.xml'

And that is it for the tests


Publishing coverage results with Azure DevOps


Let us now focus on the coverage results. We have to pay attention to the coverage reporter with jest in this case, as Azure Devops want the results created by the cobertura reporter. But we have the same problem again: for each lib and app, so multiple coverage files will be created, and we have to merge them. Let us see, how we can do this:

First we have to modify the jest.preset.js file again, adding the cobertura reporter

const nxPreset = require('@nx/jest/preset').default;

module.exports = {
  ...nxPreset,
  // ... other properties
  reporters: [
    'default',
    [
      'jest-junit',
      {
        outputDirectory: `${process.env.NX_WORKSPACE_ROOT}/test_results`,
        outputName: `${process.env['NX_TASK_TARGET_PROJECT']}.junit.xml`,
      },
    ],
  ],
  coverageReporters: ['lcov', 'html', 'cobertura'], // Add cobertura here!!!
};

The outcome is multiple cobertura-coverage.xml files inside the folders in the coverage folder. We have to gather them all and combine them now, because Azure DevOps again wants to have one single file.

For this, we can write a simple JavaScript file with dependencies to the fs-extra npm package.

In the tools folder on root level, we can add a file which searches for all cobertura-coverage.xml files and merges them. To merge, we can install the cobertura-merge package. The file creates the command for cobertura coverage and creates a single file in the test_results folder with the command.

tools/merge-cobertura-reports.js

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

const TEST_RESULTS_FOLDER = 'coverage';
const COBERTURA_FILENAME = 'cobertura-coverage.xml';
const COBERTURA_TARGET_FILENAME =
  'test_results/cobertura-coverage-complete.xml';

async function mergeCoberturaFiles() {
  const allCoberturaTestFiles = getFilesRecursively(TEST_RESULTS_FOLDER);
  const commandPrefix = `npx cobertura-merge -o ${COBERTURA_TARGET_FILENAME}`;
  const allPackagesCommand = allCoberturaTestFiles
    .map((file, index) => `package${index + 1}=${file}`)
    .join(' ');
  const completeCommand = `${commandPrefix} ${allPackagesCommand}`;

  console.log(completeCommand);
  exec(completeCommand, (err, stdout, stderr) => {
    if (err) {
      console.error(err);
      return;
    }

    console.log(stdout);
    console.error(stderr);
  });
}

function getFilesRecursively(dir, files = []) {
  const fileList = fs.readdirSync(dir);

  for (const file of fileList) {
    const name = `${dir}/${file}`;

    if (fs.statSync(name).isDirectory()) {
      getFilesRecursively(name, files);
    } else {
      const filename = path.basename(name);

      if (filename === COBERTURA_FILENAME) {
        files.push(name);
      }
    }
  }

  return files;
}

mergeCoberturaFiles();

The outcome here is a file test_results/cobertura-coverage-complete.xml which we then can present to the pipeline.

Let us first trigger this file from the pipeline over a package.json command

{
  "name": "my-private-repo",
  "scripts": {
    "ng": "nx",
    // .. remove for brevity
    "test:affected": "nx affected --target=test --base=origin/main --coverage",
    "merge-junit": "junit-merge --dir=./test_results --out=./test_results/complete-junit.xml",
    "merge-cobertura": "node ./tools/merge-cobertura-reports.js"
  },
  "private": true,
  "dependencies": {
    // ...
  },
  "devDependencies": {
    // ...
  },
}

And in the pipeline, we can now run this command and provide the information to the pipeline

      - task: Npm@1
        displayName: Summarizing affected coverage files
        continueOnError: true
        inputs:
          command: custom
          verbose: true
          customCommand: 'run merge-cobertura'

      - task: PublishCodeCoverageResults@2
        displayName: Publishing Coverage Results to Pipeline
        continueOnError: true
        inputs:
          summaryFileLocation: 'test_results/cobertura-coverage-complete.xml'

I hope this helped a bit!

Thanks, Fabian