Mert Özen Further With Every Line
DevOps Jenkins — #07

A .NET Pipeline with Jenkins: dotnet SDK Container, Test Results, Coverage and Publish Artifacts

Mert Özen Nov 8, 2026 9 min 4 views
A .NET Pipeline with Jenkins: dotnet SDK Container, Test Results, Coverage and Publish Artifacts

We restore, build, test and publish Bookstore.Api in the .NET SDK container and record the JUnit report, Cobertura coverage and a fingerprinted artifact in Jenkins. The NuGet cache volume's permission error and the time it saves, xUnit v3's JUnit output with Microsoft.Testing.Platform in .NET 10, the coverage quality gate, and a build that turns red when no test report is found.

Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), the .NET 10 SDK (Software Development Kit) 10.0.403, xUnit.net v3 4.0.2, Microsoft.Testing.Extensions.CodeCoverage 18.12, the JUnit plugin 1458 and the Coverage plugin 4.3567.

Guess First

The test stage in the pipeline has a post { always { junit 'TestResults/**/*.junit.xml' } } step. One day someone changes the test project's output folder, and the report file is no longer created at that path. The tests still run and pass. What color is the build: green, yellow or red? And would the answer change if allowEmptyResults: true were written there?

The Jenkinsfile for Bookstore.Api

The .NET part of the Jenkinsfile in the Bookstore repository, from start to finish:

pipeline {
    agent {
        docker {
            image 'mcr.microsoft.com/dotnet/sdk:10.0'
            label 'docker'
            args '-v bookstore-nuget:/nuget'
        }
    }
    options {
        timeout(time: 20, unit: 'MINUTES')
        timestamps()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        disableConcurrentBuilds()
    }
    environment {
        NUGET_PACKAGES = '/nuget/packages'
        DOTNET_CLI_TELEMETRY_OPTOUT = '1'
        DOTNET_NOLOGO = '1'
        VERSION = "1.4.${BUILD_NUMBER}"
    }
    stages {
        stage('Restore') {
            steps {
                sh 'dotnet restore Bookstore.slnx'
            }
        }
        stage('Build') {
            steps {
                sh 'dotnet build Bookstore.slnx -c Release --no-restore -p:Version=$VERSION'
            }
        }
        stage('Test') {
            steps {
                sh '''
                    dotnet test --solution Bookstore.slnx -c Release --no-build \
                        --results-directory TestResults \
                        --report-xunit-junit --report-xunit-junit-filename bookstore-api.junit.xml \
                        --coverage --coverage-output-format cobertura --coverage-output bookstore-api.cobertura.xml
                '''
            }
            post {
                always {
                    junit testResults: 'TestResults/**/*.junit.xml', allowEmptyResults: false
                    recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'TestResults/**/*.cobertura.xml']],
                                   sourceCodeRetention: 'NEVER',
                                   qualityGates: [[threshold: 80.0, metric: 'LINE', baseline: 'PROJECT', criticality: 'UNSTABLE']])
                }
            }
        }
        stage('Publish') {
            steps {
                sh 'dotnet publish src/Bookstore.Api -c Release --no-build -p:Version=$VERSION -o out/api'
                sh 'tar -czf bookstore-api-$VERSION.tgz -C out/api .'
                archiveArtifacts artifacts: 'bookstore-api-*.tgz', fingerprint: true
            }
        }
    }
}

The stages are small and each one is a single dotnet command. That is deliberate: the same commands run on a developer's machine too, and Jenkins only manages the order, the environment and the reports. --no-restore and --no-build make sure each stage does not repeat the previous one's work; making restore a separate stage also shows immediately in the log and in Pipeline Graph View which step is slow. VERSION is derived from the build number and written both into the assembly and into the artifact name.

The SDK Container and the NuGet Cache

As we saw in the fourth post, Docker Pipeline runs the container with -u 1000:1000, and the container starts from scratch on every build. If you download NuGet packages inside the container, every build downloads all of them again. The fix is to move the packages folder into a persistent Docker volume: args '-v bookstore-nuget:/nuget' and NUGET_PACKAGES = '/nuget/packages'. On the first attempt I got this:

/usr/share/dotnet/sdk/10.0.403/NuGet.targets(198,5): error : Access to the path '/nuget/packages' is denied. [/home/jenkins/agent/workspace/dotnet-try/Bookstore.slnx]
/usr/share/dotnet/sdk/10.0.403/NuGet.targets(198,5): error :   Permission denied [/home/jenkins/agent/workspace/dotnet-try/Bookstore.slnx]

Docker creates a new named volume owned by root, while the build runs as uid 1000. I prepared the volume once, on the Docker daemon the agent uses, with the right ownership:

$ docker run --rm -u 0 -v bookstore-nuget:/nuget mcr.microsoft.com/dotnet/sdk:10.0 sh -c 'chown 1000:1000 /nuget && ls -ld /nuget'
drwxr-xr-x 2 ubuntu ubuntu 4096 Nov  8 04:31 /nuget

(ubuntu is the name of uid 1000 in the SDK image.) After that, the effect of the cache is clearly visible in the log. The test project's restore with the volume empty:

Restored /home/jenkins/agent/workspace/dotnet-try/tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj (in 6.29 sec).

On the next build, with the same volume filled:

Restored /home/jenkins/agent/workspace/dotnet-try/tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj (in 970 ms).

For six test packages the difference is a few seconds; in a real solution with hundreds of packages it adds up to minutes, and traffic to the NuGet feed drops by the same amount. A shared cache has one risk: if two builds using the same volume try to write the same package at the same time, NuGet tries to coordinate this with locks, but keeping the volume per agent and watching its size is still a good idea. If you want stricter reproducibility, use packages.lock.json with dotnet restore --locked-mode; then the cache only adds speed and cannot change which version arrives.

dotnet test in .NET 10: VSTest or Microsoft.Testing.Platform?

With .NET 10, dotnet test can run on two separate engines. The older one is VSTest; you get the test report with --logger (trx is built in, the JunitXml.TestLogger package for JUnit). The newer one is Microsoft.Testing.Platform (MTP): the test project becomes an executable that can run on its own, and features such as reporting and coverage come from extensions added to the test project. Which engine is used is decided by the global.json in the repository root:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

Bookstore's test project uses xUnit.net v3, and xUnit v3 brings its own report formats in MTP mode. The relevant lines from the output of dotnet test --project tests/Bookstore.Api.Tests --help:

  --coverage                        Collect the code coverage using dotnet-coverage tool.
  --coverage-output                 The output of the generated code coverage report.
  --coverage-output-format          Output file format. Supported values: 'coverage', 'xml' and 'cobertura'
  --report-xunit-junit              Enable generating JUnit (XML) report
  --report-xunit-junit-filename     The name of the generated JUnit (XML) report
  --report-xunit-trx                Enable generating TRX (XML) report

So you need neither a JUnit logger package nor a tool that converts TRX (Visual Studio Test Results) to JUnit: xUnit directly produces the XML (Extensible Markup Language) that Jenkins's JUnit plugin reads. The --coverage option comes from the Microsoft.Testing.Extensions.CodeCoverage package I added to the test project. The test project's package references:

<ItemGroup>
  <PackageReference Include="xunit.v3" Version="4.0.2" />
  <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
  <PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.12.0" />
</ItemGroup>

In MTP mode the options are written directly to dotnet test; the -- separator from VSTest is not needed. If your project stays on VSTest, the same pipeline is built with --logger "junit;LogFilePath=..." and --collect "XPlat Code Coverage"; I did not run that path in this post. We covered the tests themselves in the ASP.NET Core Web API (Application Programming Interface) series posts on unit testing and "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory"; the BookEndpointsTests here uses the pattern from that post.

Test Results and the Coverage Threshold

The log of the test stage:

[2026-11-08T04:32:25.764Z] Running tests from /home/jenkins/agent/workspace/dotnet-try/tests/Bookstore.Api.Tests/bin/Release/net10.0/Bookstore.Api.Tests.dll (net10.0|arm64)
[2026-11-08T04:32:26.017Z]   In process file artifacts produced:
[2026-11-08T04:32:26.017Z]     - /home/jenkins/agent/workspace/dotnet-try/TestResults/bookstore-api.junit.xml
[2026-11-08T04:32:26.017Z]     - /home/jenkins/agent/workspace/dotnet-try/TestResults/bookstore-api.cobertura.xml
[2026-11-08T04:32:26.017Z] Test run summary: Passed!
[2026-11-08T04:32:26.017Z]   total: 6
[2026-11-08T04:32:26.017Z]   failed: 0
[2026-11-08T04:32:26.017Z]   succeeded: 6
[2026-11-08T04:32:26.017Z]   skipped: 0
[2026-11-08T04:32:26.030Z] Recording test results
[2026-11-08T04:32:26.759Z] [Coverage] Successfully parsed file '/home/jenkins/agent/workspace/dotnet-try/TestResults/bookstore-api.cobertura.xml'
[2026-11-08T04:32:26.759Z] [Coverage] Class Coverage: 87.50% (7/8)
[2026-11-08T04:32:26.759Z] [Coverage] Method Coverage: 83.33% (10/12)
[2026-11-08T04:32:26.759Z] [Coverage] Line Coverage: 86.49% (32/37)
[2026-11-08T04:32:26.759Z] [Coverage] Branch Coverage: 78.57% (11/14)
[2026-11-08T04:32:26.804Z] [Coverage] Evaluating quality gates
[2026-11-08T04:32:26.804Z] [Coverage] -> All quality gates have been passed
[2026-11-08T04:32:26.804Z] [Coverage]    - [Whole project - Line Coverage]: ≪Success≫ - (Actual value: 86.49%, Quality gate: 80.00)

The JUnit plugin attaches the results to the build; the test trend and the list of broken tests on the build page come from this data. The same data is available from the REST (Representational State Transfer) API too:

$ curl -s -u "admin:$JENKINS_ADMIN_PASSWORD" "http://localhost:18400/job/dotnet-try/2/testReport/api/json?tree=duration,passCount,failCount,skipCount"
{"_class":"hudson.tasks.junit.TestResult","duration":0.169,"failCount":0,"passCount":6,"skipCount":0}

The Coverage plugin's quality gate makes the build UNSTABLE (yellow) below the threshold. In a build where I deliberately raised the threshold to 90:

[2026-11-08T04:32:54.793Z] [Coverage] Evaluating quality gates
[2026-11-08T04:32:54.793Z] [Coverage] -> Some quality gates have been missed: overall result is UNSTABLE
[2026-11-08T04:32:54.793Z] [Coverage]    - [Whole project - Line Coverage]: ≪Unstable≫ - (Actual value: 86.49%, Quality gate: 90.00)
Finished: UNSTABLE

With criticality: 'FAILURE' the build can be turned red instead. Rather than a fixed percentage, the approach that works better for most teams is a "changed lines" criterion such as baseline: 'MODIFIED_LINES', or preventing a drop compared to a reference build; an overall percentage does not tell you whether new code was tested. sourceCodeRetention: 'NEVER' stops the plugin from copying and keeping the source code on the controller; a good default when you think about the controller disk and where your source code ends up.

When There Are No Test Results: a False Green

The answer to the opening question. When the report file is not created, the junit step does this:

Recording test results
No test report files were found. Configuration error?
Error when executing always post condition:
hudson.AbortException: No test report files were found. Configuration error?
ERROR: No test report files were found. Configuration error?
Finished: FAILURE

The build is red, and that is good: showing green while the answer to "did the tests run?" is unclear is the worst outcome. With allowEmptyResults: true, the build would have been green in the same situation and nobody would have noticed. That is why I deliberately wrote allowEmptyResults: false (the default value) explicitly in the Jenkinsfile. If one of the tests breaks, MTP returns a non-zero exit code, the sh step fails and the build turns red; thanks to post { always { ... } } the JUnit report is still recorded and the build page shows which test broke.

Publish, archiveArtifacts and Fingerprints

The last stage publishes the application, puts it into an archive and records it in Jenkins as an artifact. The artifact of the main build in the multibranch job and its contents:

{"_class":"org.jenkinsci.plugins.workflow.job.WorkflowRun","artifacts":[{"relativePath":"bookstore-api-1.4.3.tgz"}]}
./
./Bookstore.Api.pdb
./appsettings.Development.json
./Bookstore.Api.deps.json
./Bookstore.Api.runtimeconfig.json
./Bookstore.Api.staticwebassets.endpoints.json
./Bookstore.Api.dll
./appsettings.json

fingerprint: true records the file's MD5 (Message-Digest Algorithm 5) digest. When you ask Jenkins with the digest, it tells you which build of which job produced the file:

{"_class":"hudson.model.Fingerprint","fileName":"bookstore-api-1.4.3.tgz","original":{"name":"bookstore/main","number":3}}

This is the cheapest way to answer "which commit did the package in production come from?". In the ninth post the deploy stage will use the same artifact; it will not build again. Note that appsettings.Development.json is in the package too: harmless, but it does not need to be in a production package; you can leave it out with CopyToPublishDirectory in the csproj.

Archived artifacts live on the controller disk. For small projects that is fine; as you grow, you need to send packages to an artifact repository (Nexus, Artifactory, GitHub Packages or a NuGet feed) and keep only the reference in Jenkins. Pushing to a NuGet feed, written with the rules from the fifth post, looks like this (nothing was pushed to a real feed in this post):

withCredentials([string(credentialsId: 'nuget-api-key', variable: 'NUGET_API_KEY')]) {
    sh 'dotnet nuget push "out/*.nupkg" --source https://nuget.example/v3/index.json --api-key "$NUGET_API_KEY"'
}

In Production

Pin the SDK image tag. mcr.microsoft.com/dotnet/sdk:10.0 moves with every patch release; as I write this it was 10.0.403. rollForward: latestFeature in global.json allows that drift; if you want fully reproducible builds, pull the image with an exact tag such as 10.0.403 or by digest.

Keep the unit test stage fast. If integration tests need a database, leave them to a separate stage and Testcontainers; you can also split it as fast feedback on PR (Pull Request) builds and full verification on main.

Watch the cache volumes. The NuGet cache grows to gigabytes over time; what fills an agent's disk is often the cache, not the workspace.

Common Pitfalls

Restoring from scratch on every build. An SDK container without a cache downloads every package on every build; the build slows down, and a feed outage stops all of CI (Continuous Integration).

A green build with no test results. allowEmptyResults: true or a wrong glob pattern shows tests that never ran as "passed".

Volume ownership. A root-owned named volume gives "Access denied" in a container running as uid 1000; the error comes from NuGet, but the cause is Docker.

Rebuilding in the deploy stage. The binary that was tested and the binary that is deployed must be the same; keep the publish output as an artifact and use that in the deploy.

Treating the coverage threshold as a goal. 80 percent coverage does not mean the important code is tested; the threshold is more useful as an alarm that catches a drop.

A Small Experiment

★☆☆ Adapt this Jenkinsfile to your own .NET project and run it. Compare restore times with and without the cache.

★★☆ Break a test on purpose. Observe the build's color, how the broken test appears in the JUnit report, and whether the Publish stage runs.

★★★ Define the coverage quality gate with the MODIFIED_LINES baseline instead of the whole project, add a new untested method in a PR, and examine whether the gate catches it.

The API side is ready. In the next post we add the Angular application to the same pipeline: a Node container, the npm cache, lint, tests with Vitest, budgets, and combining both applications into a single package with stash/unstash.