Nx and Azure DevOps Integration for Test and Coverage Reporting
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