xUnit v3 ile Test Projesi: Microsoft Testing Platform, dotnet test ve İlk Testler
xUnit.net v3 test projesi bir executable'dır ve 4.0 ile varsayılan olarak Microsoft Testing Platform v2 üzerinde çalışır; .NET 10 SDK'da global.json'a test runner ayarı eklenince dotnet test VSTest projelerini reddeder. xunit3 template'i, paket zinciri, dotnet test ve dotnet run, Assert.Throws/ThrowsAsync, TestContext.Current.CancellationToken ile xUnit1051, filtreler, TRX raporu ve exit code 8.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2 ve Microsoft Testing Platform v2 ile derlenip denenmiştir; kullanılan .NET SDK (Software Development Kit) sürümü 10.0.402.
Önce Tahmin Et
Yeni bir .NET 10 çözümüne test projesi ekliyorsun. Çözümün kökündeki global.json, ekibin aldığı bir kararla dotnet test'i Microsoft Testing Platform moduna almış. Projenin .csproj'unu ise internetteki popüler bir xUnit tutorial'ından kopyaladın:
// global.json
{
"test": { "runner": "Microsoft.Testing.Platform" }
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
İçinde tek bir [Fact] var, Assert.Equal(4, 2 + 2). dotnet test çalıştırınca ne olur: test geçer mi, kalır mı, hiç çalışmaz mı?
Cevap: Test Hiç Çalışmıyor
global.json defines test runner to be Microsoft.Testing.Platform. All projects must use that test runner.
The following test projects are using VSTest test runner:
V2Copy.Tests.csproj
See https://aka.ms/dotnet-test/mtp for more information.
Kopyalanan paketler xUnit.net v2'nin paketleri: xunit, VSTest adapter'ı xunit.runner.visualstudio ve VSTest altyapısını getiren Microsoft.NET.Test.Sdk. Bu proje bir VSTest projesi; .NET 10 SDK'nın yeni MTP (Microsoft Testing Platform) modu ise yalnız MTP projelerini çalıştırıyor ve karışık çözümü baştan reddediyor. Bu yazıda doğru projeyi kuruyoruz: xUnit.net v3'ün template'i, ürettiği dosyalar, dotnet test'in iki modu ve ilk testler.
xUnit v3'te Test Projesi Bir Executable
v3 template'leri ayrı bir paket olarak gelir. Kurulum ve proje oluşturma:
dotnet new install xunit.v3.templates
dotnet new xunit3 -o Bookstore.Ordering.Tests -f net10.0
dotnet add Bookstore.Ordering.Tests reference Bookstore.Ordering
Template'in varsayılan hedefi hâlâ net8.0; -f net10.0 vermezsen proje .NET 8'i hedefler. Üretilen .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.2" />
</ItemGroup>
</Project>
Üç şey v2'den farklı. Birincisi OutputType: v3 test projesi bir library değil, kendi Main'i olan bir executable. Test runner'ı projeyi yükleyen dış bir process değil, projenin kendisi. Bu sayede test assembly'si kendi bağımlılıklarıyla, kendi process'inde çalışır; v2'deki app domain ve assembly yükleme sorunlarının çoğu ortadan kalkar.
İkincisi paket. NuGet'teki paket zinciri şöyle: xunit.v3 bir meta paket ve 4.0.2'de yalnız xunit.v3.mtp-v2'ye bağlı; o da xunit.v3.core.mtp-v2 (framework ve MTP v2 entegrasyonu), xunit.v3.assert ve xunit.analyzers 2.2.0'ı getiriyor. Yani xunit.v3 ile xunit.v3.mtp-v2 bugün aynı yere çıkıyor; template açık olan adı tercih ediyor. Desteklenen hedefler .NET 8 ve üstü ile .NET Framework 4.7.2 ve üstü.
Üçüncüsü MTP. 4.0 sürümüyle (2026-08-14) MTP v1 desteği bitti ve MTP v2 varsayılan oldu. VSTest ile çalışmaya devam etmek isteyen ekipler için -mtp-off paketleri var. Template'te --test-runner vstest seçeneği bunu üretiyor:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.2" />
</ItemGroup>
Template bir de global.json'a dokunuyor. Klasörde global.json yoksa projenin yanına "test": { "runner": "Microsoft.Testing.Platform" } içeren bir tane oluşturuyor; yukarıdaki klasörlerden birinde varsa ona bu bölümü ekliyor ve çıktıya "Successfully modified global.json." yazıyor. Template'i çözümün içinde çalıştırdığında kökteki global.json'un değiştiğini commit'ten önce fark etmekte fayda var.
dotnet test'in İki Modu: VSTest ve MTP
.NET 10 SDK'ya kadar dotnet test yalnız VSTest'i tanıyordu: VSTest MSBuild target'ını çağırıyor, o da vstest.console'u çalıştırıyordu. MTP projeleri bu modda ancak TestingPlatformDotnetTestSupport property'siyle ve argümanları ekstra bir --'dan sonra vererek çalışabiliyordu. .NET 10 SDK yeni bir mod getirdi; global.json'daki test.runner değeri Microsoft.Testing.Platform olduğunda dotnet test doğrudan MTP uygulamalarını çalıştırır. Microsoft dokümanı bu modda ekstra --'nın zorunlu olmadığını, proje ve solution'ın --project ve --solution ile verildiğini yazar. Microsoft'a göre VSTest moduyla MTP çalıştırmak artık legacy; MTP v2 ile .NET 10 SDK'da bu destek kaldırılıyor.
Template'i net8.0 ile oluşturduğunda .csproj'a TestingPlatformDotnetTestSupport ekleniyor, net10.0 ile eklenmiyor; template .NET 10 için yeni modu varsayıyor. Testlerin çalıştığı çıktı:
Running tests from …/p02/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
…/p02/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) passed (128ms)
Test run summary: Passed!
total: 5
failed: 0
succeeded: 5
skipped: 0
duration: 374ms
Projeler executable olduğu için dotnet run da testleri çalıştırır. Template'in varsayılan --command-line xunit seçeneği, bu durumda xUnit'in kendi komut satırını kullanır:
xUnit.net v3 In-Process Runner v4.0.2+0c7aa4991f (64-bit .NET 10.0.13)
Discovering: Bookstore.Ordering.Tests
Discovered: Bookstore.Ordering.Tests
Starting: Bookstore.Ordering.Tests
Finished: Bookstore.Ordering.Tests (ID = '921d85dda4575876526c166eee2ad6cf591728ea365371a65bbcc300f6a8f040')
=== TEST EXECUTION SUMMARY ===
Bookstore.Ordering.Tests Total: 5, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.054s
Aynı executable, dotnet test üzerinden çağrıldığında MTP protokolüyle konuşur; doğrudan çalıştırıldığında kendi runner'ını gösterir. Hata ayıklarken dotnet run, CI'da (Continuous Integration) ve günlük kullanımda dotnet test pratik bir ayrım.
İlk Testler: Fact, Assert.Equal ve Exception'lar
Test edilen kod, önceki yazıdaki Order'ın biraz genişlemiş hâli ve bir iptal servisi:
namespace Bookstore.Ordering;
public enum OrderStatus { Draft, Placed, Shipped, Cancelled }
public sealed class Order(Guid id)
{
private readonly List<OrderLine> lines = [];
public Guid Id { get; } = id;
public OrderStatus Status { get; internal set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => lines;
public decimal Subtotal => lines.Sum(l => l.Total);
public void AddLine(Book book, int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
lines.Add(new OrderLine(book, quantity));
}
}
public interface IOrderRepository
{
Task<Order?> FindAsync(Guid id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
public sealed class OrderCancellation(IOrderRepository orders)
{
public async Task CancelAsync(Guid orderId, CancellationToken ct = default)
{
Order order = await orders.FindAsync(orderId, ct)
?? throw new KeyNotFoundException($"Order {orderId} was not found.");
if (order.Status == OrderStatus.Shipped)
{
throw new InvalidOperationException("A shipped order cannot be cancelled.");
}
order.Status = OrderStatus.Cancelled;
await orders.SaveAsync(order, ct);
}
}
Senkron testler:
public sealed class OrderTests
{
private static readonly Book Refactoring = new("978-0134757599", "Refactoring", 48m);
[Fact]
public void Subtotal_sums_price_times_quantity_of_each_line()
{
Order order = new(Guid.NewGuid());
order.AddLine(Refactoring, 2);
Assert.Equal(96m, order.Subtotal);
}
[Fact]
public void AddLine_rejects_zero_quantity()
{
Order order = new(Guid.NewGuid());
ArgumentOutOfRangeException ex =
Assert.Throws<ArgumentOutOfRangeException>(() => order.AddLine(Refactoring, 0));
Assert.Equal("quantity", ex.ParamName);
}
}
Assert.Equal'ın ilk argümanı beklenen, ikincisi gerçek değer; ters yazınca test yine çalışır ama hata mesajı "Expected" ile "Actual"ı ters gösterir. Assert.Throws<T> tam olarak T tipini bekler: ArgumentException yazsaydık, fırlatılan ArgumentOutOfRangeException onun alt tipi olduğu hâlde test kalırdı. Alt tipleri de kabul etmek istiyorsan Assert.ThrowsAny<T> var. Dönen exception'ı bir değişkene alıp ParamName'i kontrol etmek, doğru argümanın reddedildiğini garantiler.
Async testler async Task döner ve Assert.ThrowsAsync kullanır:
public sealed class OrderCancellationTests
{
[Fact]
public async Task Draft_order_becomes_cancelled_and_is_saved()
{
Order order = new(Guid.NewGuid());
InMemoryOrderRepository orders = new(order);
OrderCancellation sut = new(orders);
await sut.CancelAsync(order.Id, TestContext.Current.CancellationToken);
Assert.Equal(OrderStatus.Cancelled, order.Status);
Assert.Equal(1, orders.SaveCount);
}
[Fact]
public async Task Shipped_order_cannot_be_cancelled()
{
Order order = new(Guid.NewGuid()) { Status = OrderStatus.Shipped };
OrderCancellation sut = new(new InMemoryOrderRepository(order));
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.CancelAsync(order.Id, TestContext.Current.CancellationToken));
Assert.Equal("A shipped order cannot be cancelled.", ex.Message);
}
[Fact]
public async Task Unknown_order_id_throws_key_not_found()
{
OrderCancellation sut = new(new InMemoryOrderRepository());
await Assert.ThrowsAsync<KeyNotFoundException>(
() => sut.CancelAsync(Guid.NewGuid()));
}
}
InMemoryOrderRepository, test projesinde duran yirmi satırlık bir class: bir Dictionary ve kaç kez kaydedildiğini sayan bir sayaç. Test double türlerini 06. yazıda ayrıntılı konuşacağız. Assert.ThrowsAsync'i await etmeyi unutmak klasik bir hatadır: dönen Task beklenmezse test, exception fırlasa da fırlamasa da geçer. xUnit analyzer'ları bunu xUnit2021 hatasıyla derleme sırasında yakalıyor: "Assert.ThrowsAsync is async. The resulting task should be awaited".
TestContext.Current.CancellationToken ve xUnit1051
Son testte CancelAsync'e token vermedik. Build çıktısı:
…/p02/Bookstore.Ordering.Tests/OrderCancellationTests.cs(38,19): warning xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
v3, her test için bir TestContext sunar; TestContext.Current o anki testin bağlamını verir. İçindeki CancellationToken, test iptal edildiğinde ya da zaman aşımına uğradığında tetiklenir: Test Explorer'da "Stop" dediğinde, runner kapanırken ya da [Fact(Timeout = 2000)] süresi dolduğunda. Token'ı alt çağrılara geçirirsen uzun süren bir test bekletmeden durur. xUnit1051 uyarısı bu token'ı kabul eden ama almayan her çağrıyı işaretler. Uyarıyı susturmak yerine token'ı geçirmek doğru alışkanlık; özellikle 11. yazıda göreceğimiz zamanlayıcı ve background service testlerinde bunun farkı büyük.
TestContext yalnız token taşımaz: SendDiagnosticMessage, AddAttachment, AddWarning ve testler arası veri paylaşımı için KeyValueStorage da orada. xUnit dokümanının uyarısı önemli: bağlam bir anlık görüntüdür, saklayıp sonra kullanma, ihtiyacın olduğu anda TestContext.Current'tan oku. 05. yazıda bu API'lerin (Application Programming Interface) yaşam döngüsüyle ilişkisini göreceğiz.
Filtreleme, Rapor ve CI
MTP modunda filtreler doğrudan dotnet test'e verilir. xUnit'in "basit" filtreleri class, method, namespace, trait ve display name için var; hepsinin --filter-not-... karşılığı da mevcut. Örnek:
dotnet test --filter-class "*OrderCancellationTests"
dotnet test --filter-method "*Shipped_order_cannot_be_cancelled"
dotnet test --filter-trait "Category=Smoke"
dotnet test --filter "FullyQualifiedName~Shipped"
dotnet test --list-tests
İlk komut 3 testi, dördüncüsü 1 testi çalıştırdı. --filter, 4.0 ile gelen VSTest filtre syntax'ı; VSTest'ten taşınan pipeline'lar için kolaylık. Bir de --filter-query ile xUnit'in sorgu dili var. Bu üç filtre türü birbiriyle birleştirilemiyor: yardım çıktısı bunu her seçeneğin altında açıkça yazıyor.
Filtre hiçbir testle eşleşmediğinde ne olduğunu bilmek CI için önemli:
Exit code: 8
Test run summary: Zero tests ran
error: 1
total: 0
failed: 0
succeeded: 0
skipped: 0
duration: 228ms
Test run completed with non-success exit code: 8 (see: https://aka.ms/testingplatform/exitcodes)
MTP, sıfır test çalışmasını başarı saymıyor ve 8 koduyla çıkıyor. Bu iyi bir varsayılan: yanlış yazılmış bir filtre yüzünden pipeline'ın sessizce yeşil kalmasını engelliyor. Bilinçli olarak boş kalabilecek bir adımda --ignore-exit-code 8 kullanılabilir. Daha sıkı bir koruma için --minimum-expected-tests ile beklenen en az test sayısı verilebiliyor.
CI sistemleri genelde test sonucunu bir dosyadan okur. xUnit'in MTP raporları 4.0'da --report-xunit- önekini aldı; TRX (Visual Studio Test Results) dosyası için:
dotnet test --report-xunit-trx --report-xunit-trx-filename unit-tests.trx --results-directory ../TestResults
In process file artifacts produced:
- …/p02/TestResults/unit-tests.trx
Aynı aileden JUnit, NUnit, CTRF (Common Test Report Format), HTML (HyperText Markup Language) ve Markdown raporları da var. Dosya adını vermezsen TRX adı kullanıcı adı, makine adı ve zaman damgasından oluşuyor; CI'da artifact olarak toplamak için sabit bir ad vermek daha kolay. Pipeline'da test adımının sırası ve flaky test yönetimi CI/CD Temelleri serisinin konusu.
MSTest, NUnit ve TUnit ile Kısa Karşılaştırma
.NET'te dört yaygın test framework'ü var ve dördü de MTP'yi destekliyor. Kavramlar büyük ölçüde aynı; farklar isimlerde ve birkaç varsayılanda:
| Konu | xUnit.net v3 4.0.2 | MSTest 4.5.2 | NUnit 5.0.0 | TUnit 1.81.10 |
|---|---|---|---|---|
| Test | [Fact] | [TestMethod] | [Test] | [Test] |
| Veri odaklı | [Theory] + [InlineData] | [DataRow] | [TestCase] | [Arguments] |
| Test başına instance | Her test için yeni | Her test için yeni | Varsayılan: fixture başına bir | Her test için yeni |
| Setup | Constructor, IAsyncLifetime | [TestInitialize] | [SetUp] | [Before(Test)] |
| Keşif | Reflection; Native AOT (Ahead-of-Time) için ayrı paket | Reflection | Reflection | Source generator |
NUnit'in varsayılanı dikkat ister: aynı fixture'daki testler aynı instance'ı paylaşır, field'larda kalan state bir testten ötekine taşınabilir. xUnit her test için yeni instance oluşturduğu için bu tür hatalar baştan engellenir; 05. yazının konusu. TUnit en genç olanı; testleri derleme anında source generator ile keşfediyor ve yalnız MTP üzerinde çalışıyor. Seçim ekibin alışkanlığına kalmış bir konu; bu seri xUnit kullanıyor çünkü .NET ekosisteminde en yaygın olanlardan biri; ASP.NET Core ve Entity Framework Core'un (EF Core) kendi test suite'leri de xUnit ile yazılmış.
Üretimde Dikkat
Bir çözümde tek runner. Microsoft dokümanı VSTest ve MTP projelerinin aynı solution'da karıştırılmasını desteklenmeyen bir senaryo olarak tanımlıyor; .NET 10 SDK'nın MTP modu ise bunu yukarıdaki hata mesajıyla doğrudan reddediyor. Geçişi proje proje yapacaksan, geçiş bitene kadar VSTest modunda kalıp MTP projelerini TestingPlatformDotnetTestSupport ile çalıştırmak ya da tüm projeleri aynı anda taşımak gerekiyor.
Sürümleri merkezden yönet. Birden çok test projesi varsa xunit.v3.mtp-v2 sürümünü Directory.Packages.props (Central Package Management) içinde tut. Projelerden birinin eski bir 3.x sürümünde kalması, MTP v1 ile v2'yi aynı çözümde buluşturur.
Exit code'lara güven. MTP'nin exit code'ları anlamlı: 0 başarı, 2 en az bir test kaldı, 8 hiç test çalışmadı. CI script'inde çıktıyı parse etmek yerine bu kodlara bakmak daha sağlam.
IDE (Integrated Development Environment) desteği. 4.0, projede IsTestingPlatformApplication MSBuild property'sini true yapıyor; araçlar projenin bir MTP uygulaması olduğunu bu property'den anlayabiliyor. Test Explorer'da testler görünmüyorsa önce IDE'nin MTP desteği olan bir sürümde olduğunu kontrol et.
Sık Düşülen Tuzaklar
v2 tutorial'ından paket kopyalamak. xunit + xunit.runner.visualstudio 3.x + Microsoft.NET.Test.Sdk üçlüsü v2'dir. v3 paketlerinin adında her zaman v3 geçer. Kopyaladığın satırda v3 yoksa projeyi v2 ile kuruyorsun.
v3 projesine VSTest paketlerini de eklemek. xunit.v3 ile birlikte Microsoft.NET.Test.Sdk ve xunit.runner.visualstudio 4.0.1'i ekleyen bir projeyi denedim: MTP modunda testler çalıştı, ama iki paket hiçbir işe yaramadan bağımlılık listesinde duruyor ve gelecekte "bu proje hangi runner'ı kullanıyor?" sorusunu belirsizleştiriyor. VSTest istiyorsan -mtp-off paketini, MTP istiyorsan yalnız -mtp-v2'yi kullan.
Template'in net8.0 varsayılanını fark etmemek. -f net10.0 vermeden oluşturulan proje, çözümün geri kalanı .NET 10 iken .NET 8'i hedefler ve TestingPlatformDotnetTestSupport gibi .NET 10 modunda gereksiz bir property ile gelir.
async void test yazmak. v2'de çalışan async void test method'ları v3'te desteklenmiyor; analyzer xUnit1049 hatasıyla derlemeyi durduruyor ("Support for 'async void' unit tests has been removed from xUnit.net v3"). Async testin dönüş tipi Task ya da ValueTask olmalı.
Küçük Bir Deneme
★☆☆ dotnet new xunit3 ile -f vermeden bir proje oluştur ve .csproj'u yukarıdakiyle karşılaştır. Ardından --test-runner vstest ile bir tane daha oluştur; iki proje aynı solution'dayken dotnet test ne diyor?
★★☆ Shipped_order_cannot_be_cancelled testinde Assert.ThrowsAsync'in önündeki await'i sil ve derlemeyi dene. Ardından await'i geri koy, CancelAsync'teki throw satırını yoruma al ve testin hata mesajını oku: hangi satır, hangi beklenti?
★★★ Bir test'e [Fact(Timeout = 500)] ekle ve içinde await Task.Delay(5000, TestContext.Current.CancellationToken) çağır. Sonra token'ı kaldırıp aynı testi çalıştır. İki koşunun süresini ve sonucunu karşılaştır.
Bir sonraki yazıda bu testlerin kendisine dönüyoruz: Arrange/Act/Assert'in sınırları, isimlendirme, tek davranış kuralı ve hata mesajı okunabilen assertion'lar; Shouldly, AwesomeAssertions ve FluentAssertions 8'in lisans değişikliği.