Jenkins ile .NET Pipeline: dotnet SDK Container'ı, Test Sonuçları, Coverage ve Publish Artifact'ı
Bookstore.Api'yi .NET SDK container'ında restore, build, test ve publish edip JUnit raporunu, Cobertura coverage'ı ve fingerprint'li artifact'ı Jenkins'e kaydediyoruz. NuGet cache volume'ünün izin hatası ve kazandırdığı süre, .NET 10'da Microsoft.Testing.Platform ile xUnit v3'ün JUnit çıktısı, coverage quality gate'i ve test raporu bulunamayınca kırmızıya dönen build.
Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), .NET 10 SDK (Software Development Kit) 10.0.403, xUnit.net v3 4.0.2, Microsoft.Testing.Extensions.CodeCoverage 18.12, JUnit plugin'i 1458 ve Coverage plugin'i 4.3567 ile denenmiştir.
Önce Tahmin Et
Pipeline'da test stage'inin post { always { junit 'TestResults/**/*.junit.xml' } } adımı var. Bir gün birisi test projesinin çıktı klasörünü değiştiriyor ve rapor dosyası artık o path'te oluşmuyor. Testler yine koşuyor ve geçiyor. Build ne renk olur: yeşil mi, sarı mı, kırmızı mı? Ve allowEmptyResults: true yazılı olsaydı cevap değişir miydi?
Bookstore.Api İçin Jenkinsfile
Bookstore repository'sindeki Jenkinsfile'ın .NET kısmı, baştan sona:
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
}
}
}
}
Stage'ler küçük ve her biri tek bir dotnet komutu. Bu bilinçli: aynı komutlar geliştiricinin makinesinde de çalışıyor, Jenkins yalnızca sırayı, ortamı ve raporları yönetiyor. --no-restore ve --no-build her stage'in bir öncekinin işini tekrar etmemesini sağlıyor; restore'u ayrı bir stage yapmak da log'da ve Pipeline Graph View'da yavaş adımın hangisi olduğunu hemen gösteriyor. VERSION build numarasından türüyor ve hem assembly'ye hem de artifact adına yazılıyor.
SDK Container'ı ve NuGet Cache'i
Dördüncü yazıda gördüğümüz gibi Docker Pipeline container'ı -u 1000:1000 ile çalıştırıyor ve container her build'de sıfırdan açılıyor. NuGet paketlerini container'ın içine indirirsen her build hepsini yeniden indirir. Çözüm, paket klasörünü kalıcı bir Docker volume'üne almak: args '-v bookstore-nuget:/nuget' ve NUGET_PACKAGES = '/nuget/packages'. İlk denemede şunu aldım:
/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 yeni bir named volume'ü root'a ait oluşturuyor, build ise uid 1000 ile çalışıyor. Volume'ü bir kez, agent'ın kullandığı Docker daemon'ında, doğru sahiplikle hazırladım:
$ 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, SDK image'ında uid 1000'in adı.) Sonrasında cache'in etkisi log'da açıkça görünüyor. Volume boşken test projesinin restore'u:
Restored /home/jenkins/agent/workspace/dotnet-try/tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj (in 6.29 sec).
Bir sonraki build'de, aynı volume doluyken:
Restored /home/jenkins/agent/workspace/dotnet-try/tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj (in 970 ms).
Altı test paketi için fark birkaç saniye; gerçek bir solution'da yüzlerce paketle dakikalar eder ve NuGet feed'ine giden trafik de o kadar azalır. Paylaşılan bir cache'in bir riski var: aynı volume'ü kullanan iki build aynı anda aynı paketi yazmaya kalkarsa NuGet bunu kilitlerle koordine etmeye çalışır, ama volume'ü agent başına tutmak ve büyüklüğünü izlemek yine de iyi bir fikir. Daha sıkı bir tekrarlanabilirlik istersen packages.lock.json ile dotnet restore --locked-mode kullanırsın; o zaman cache yalnız hız kazandırır, hangi sürümün geleceğini değiştiremez.
.NET 10'da dotnet test: VSTest mi, Microsoft.Testing.Platform mı?
.NET 10 ile dotnet test iki ayrı motorla çalışabiliyor. Eskisi VSTest; test raporunu --logger ile alırsın (trx yerleşik, JUnit için JunitXml.TestLogger paketi). Yenisi Microsoft.Testing.Platform (MTP): test projesi kendi başına çalışabilen bir executable olur ve raporlama, coverage gibi özellikler test projesine eklenen extension'lardan gelir. Hangi motorun kullanılacağını repository kökündeki global.json belirliyor:
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Bookstore'un test projesi xUnit.net v3 kullanıyor ve xUnit v3, MTP modunda kendi rapor biçimlerini getiriyor. dotnet test --project tests/Bookstore.Api.Tests --help çıktısından ilgili satırlar:
--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
Yani ne JUnit logger paketine ne de TRX'ten (Visual Studio Test Results) JUnit'e dönüştüren bir araca ihtiyaç var: xUnit doğrudan Jenkins'in JUnit plugin'inin okuduğu XML'i (Extensible Markup Language) üretiyor. --coverage seçeneği ise test projesine eklediğim Microsoft.Testing.Extensions.CodeCoverage paketinden geliyor. Test projesinin paket referansları:
<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>
MTP modunda seçenekler doğrudan dotnet test'e yazılıyor; VSTest'teki -- ayıracına gerek yok. Projen VSTest'te kalıyorsa aynı pipeline --logger "junit;LogFilePath=..." ve --collect "XPlat Code Coverage" ile kurulur; bu yazıda o yolu çalıştırmadım. Testlerin kendisini ASP.NET Core Web API (Application Programming Interface) serisinin unit test ve "WebApplicationFactory ile Uçtan Uca Test" yazılarında anlatmıştık; buradaki BookEndpointsTests o yazıdaki pattern'i kullanıyor.
Test Sonuçları ve Coverage Eşiği
Test stage'inin log'u:
[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)
JUnit plugin'i sonuçları build'e bağlıyor; build sayfasında test trendi ve kırılan testlerin listesi bu veriden geliyor. Aynı veriye REST (Representational State Transfer) API'sinden de ulaşılıyor:
$ 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}
Coverage plugin'inin quality gate'i eşik altında build'i UNSTABLE (sarı) yapıyor. Eşiği bilerek 90'a çektiğim build'de:
[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
criticality: 'FAILURE' ile build kırmızıya da çevrilebilir. Kesin bir yüzde koymak yerine çoğu ekip için daha iyi işleyen yaklaşım baseline: 'MODIFIED_LINES' gibi "değişen satırlar" ölçütleri ya da referans build'e göre düşüşü engellemek; genel bir yüzde, yeni kodun test edilip edilmediğini söylemez. sourceCodeRetention: 'NEVER' ise plugin'in kaynak kodu controller'a kopyalayıp saklamasını engelliyor; controller diskini ve kaynak kodun nerede durduğunu düşünürsen iyi bir varsayılan.
Test Sonucu Yoksa: Yanlış Yeşil
Baştaki sorunun cevabı. Rapor dosyası oluşmadığında junit adımı:
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
Build kırmızı ve bu iyi: "testler koştu mu" sorusunun cevabı belirsizken yeşil göstermek en kötü sonuç. allowEmptyResults: true yazsaydın aynı durumda build yeşil olurdu ve kimse fark etmezdi. Bu yüzden Jenkinsfile'da allowEmptyResults: false'u (varsayılan değeri) bilerek açıkça yazdım. Testlerden biri kırılırsa ise MTP sıfırdan farklı bir exit code döner, sh adımı başarısız olur ve build kırmızıya döner; post { always { ... } } sayesinde JUnit raporu yine kaydedilir ve hangi testin kırıldığı build sayfasında görünür.
Publish, archiveArtifacts ve Fingerprint
Son stage uygulamayı yayımlıyor, bir arşive koyuyor ve Jenkins'e artifact olarak kaydediyor. Multibranch'teki main build'inin artifact'ı ve içeriği:
{"_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 dosyanın MD5 (Message-Digest Algorithm 5) özetini kaydediyor. Jenkins'e bu digest değeriyle sorduğunda dosyanın hangi job'un hangi build'inde üretildiğini söylüyor:
{"_class":"hudson.model.Fingerprint","fileName":"bookstore-api-1.4.3.tgz","original":{"name":"bookstore/main","number":3}}
Bu, "production'daki paket hangi commit'ten geldi" sorusunu cevaplamanın en ucuz yolu. Dokuzuncu yazıda deploy stage'i aynı artifact'ı kullanacak; tekrar build etmeyecek. Paketin içinde appsettings.Development.json'ın da olduğuna dikkat et: zararsız ama production paketinde olması gerekmiyor; csproj'da CopyToPublishDirectory ile dışarıda bırakabilirsin.
Arşivlenen artifact controller diskinde durur. Küçük projelerde sorun değil; büyüyünce paketleri bir artifact repository'ye (Nexus, Artifactory, GitHub Packages veya bir NuGet feed'i) göndermek ve Jenkins'te yalnız referansı tutmak gerekir. Bir NuGet feed'ine gönderim, beşinci yazıdaki kurallarla şöyle yazılır (bu yazıda gerçek bir feed'e gönderilmedi):
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"'
}
Üretimde Dikkat
SDK image tag'ini sabitle. mcr.microsoft.com/dotnet/sdk:10.0 her patch sürümünde kayar; bu yazıyı yazarken 10.0.403'tü. global.json'daki rollForward: latestFeature bu kaymaya izin veriyor; build'lerin tamamen tekrarlanabilir olmasını istiyorsan image'ı 10.0.403 gibi tam bir tag'le ya da digest ile çek.
Unit test stage'ini hızlı tut. Integration test'ler bir veritabanı gerektiriyorsa onları ayrı bir stage'e ve Testcontainers'a bırak; PR (Pull Request) build'lerinde hızlı geri bildirim, main'de tam doğrulama gibi bir ayrım da yapabilirsin.
Cache volume'lerini izle. NuGet cache'i zamanla gigabaytlara ulaşır; agent'ın diskini dolduran şey çoğu zaman workspace değil cache'tir.
Sık Düşülen Tuzaklar
Her build'de sıfırdan restore. Cache'siz bir SDK container'ı her build'de bütün paketleri indirir; build yavaşlar ve feed kesintisinde bütün CI (Continuous Integration) durur.
Test sonucu yokken yeşil build. allowEmptyResults: true ya da yanlış bir glob pattern'i, hiç koşmamış testleri "geçti" gibi gösterir.
Volume sahipliği. Root'a ait bir named volume, uid 1000 ile çalışan container'da "Access denied" verir; hata NuGet'ten gelir ama sebep Docker'dır.
Build'i deploy stage'inde tekrarlamak. Test edilen ikili ile deploy edilen ikili aynı olmalı; publish çıktısını artifact olarak sakla ve deploy'da onu kullan.
Coverage eşiğini hedef sanmak. Yüzde 80 coverage, önemli kodun test edildiği anlamına gelmez; eşik, düşüşü yakalayan bir alarm olarak daha faydalı.
Küçük Bir Deneme
★☆☆ Kendi .NET projen için bu Jenkinsfile'ı uyarlayıp çalıştır. Restore süresini cache'li ve cache'siz karşılaştır.
★★☆ Bir testi bilerek kır. Build'in rengini, JUnit raporunda kırılan testin nasıl göründüğünü ve Publish stage'inin çalışıp çalışmadığını gözlemle.
★★★ Coverage quality gate'ini proje geneli yerine MODIFIED_LINES baseline'ıyla tanımla, bir PR'da test edilmemiş yeni bir method ekle ve gate'in bunu yakalayıp yakalamadığını incele.
API tarafı hazır. Bir sonraki yazıda Angular uygulamasını aynı pipeline'a ekliyoruz: Node container'ı, npm cache'i, lint, Vitest ile test, budget'lar ve stash/unstash ile iki uygulamayı tek pakette birleştirmek.