Mert Özen Her Satırda Daha İleri
Backend Aspire ile Dağıtık Uygulamalar — #06

Aspire ile Test: Aspire.Hosting.Testing ile Tüm Sistemi Ayağa Kaldıran Uçtan Uca Testler

Mert Özen 14 Kas 2026 12 dk 11 görüntülenme
Aspire ile Test: Aspire.Hosting.Testing ile Tüm Sistemi Ayağa Kaldıran Uçtan Uca Testler

Aspire.Hosting.Testing, AppHost'u bir test fixture'ında gerçekten başlatıp resource'lara HttpClient veriyor; birkaç kritik uçtan uca senaryo için uygun, service içi testlerin yerini almıyor. Yazı testin geliştirici veritabanına dokunmamasını, her senaryonun yalnız ihtiyacı olan container'ları başlatmasını, template'teki retry'ın POST'u tekrarlayıp 409 ürettiği hatayı ve asenkron onayı Task.Delay yerine polling ile beklemeyi gerçek çıktılarla gösteriyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6 (Aspire.Hosting.Testing 13.6.5) ve xUnit 2.9 ile derlenip denenmiştir; testler SQL Server 2025, RabbitMQ 4.3 ve Keycloak 26.6 container'larıyla koştu.

Önce Tahmin Et

Aspire'ın test template'iyle başladık ve template'in yorum satırlarında önerdiği gibi testteki HttpClient'lara standard resilience handler'ı ekledik. İlk test bir kitap ekliyor ve listede arıyor. Her test koşusunda SQL Server container'ı sıfırdan açılıyor, veritabanı boş. İlk koşuda test şu hatayla düştü:

Failed Bookstore.AppHost.Tests.CatalogTests.AddedBookAppearsInTheList [13 s]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: Created
Actual:   Conflict

Boş bir veritabanına eklenen ilk kitap nasıl 409 Conflict alır? Ve test neden 13 saniye sürdü? Cevap iki ayrı yerde: biri test tarafında, biri bir önceki yazıda verdiğimiz Redis kararında. Önce test altyapısını kuralım, sonra bu hataya dönelim.

Test Projesi ve AppHost'u Testte Başlatmak

Aspire.Hosting.Testing paketi AppHost'u bir test içinde çalıştırıyor: dotnet run ile yaptığımız gibi container'ları ve process'leri gerçekten başlatıyor, test bitince kapatıyor. Yani E2E (end-to-end; sistemin bütün parçalarını birlikte çalıştıran) test yazmanın Aspire'daki yolu. dotnet new aspire-xunit template'i xUnit 2.9 ile bir proje üretiyor; biz onu sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) tests/Bookstore.AppHost.Tests klasörüne aldık ve AppHost projesine referans verdik:

<ItemGroup>
  <PackageReference Include="Aspire.Hosting.Testing" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" />
  <PackageReference Include="xunit" />
  <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\..\src\Bookstore.AppHost\Bookstore.AppHost.csproj" />
</ItemGroup>

Referans, Projects.Bookstore_AppHost tipini üretiyor. Her test için AppHost'u yeniden başlatmak pahalı, bu yüzden dokümanın da önerdiği gibi test class'ı başına bir kez başlatıyoruz: xUnit'te IClassFixture ve IAsyncLifetime. Fixture'ın çekirdeği:

// Test class'ı başına bir AppHost. Senaryonun ihtiyaç duymadığı resource'lar başlatılmaz.
public abstract class BookstoreAppFixture(params string[] notNeeded) : IAsyncLifetime
{
    private static readonly TimeSpan StartTimeout = TimeSpan.FromMinutes(3);

    // Her koşuda üretiliyor: CI makinesinde user secrets gerekmiyor
    public string BackofficeSecret { get; } = Guid.NewGuid().ToString("N");

    public DistributedApplication App { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        using CancellationTokenSource cts = new(StartTimeout);

        IDistributedApplicationTestingBuilder builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.Bookstore_AppHost>(
        [
            $"Parameters:sql-password={Guid.NewGuid():N}Aa1!",
            $"Parameters:backoffice-client-secret={BackofficeSecret}"
        ], cts.Token);

        // ... (aşağıda)

        App = await builder.BuildAsync(cts.Token);
        await App.StartAsync(cts.Token);
        await WaitUntilReadyAsync(App, cts.Token);
    }

    protected abstract Task WaitUntilReadyAsync(DistributedApplication app, CancellationToken ct);

    public async Task DisposeAsync()
    {
        if (App is not null)
        {
            await App.DisposeAsync();
        }
    }
}

Üç noktaya dikkat. Birincisi parameter'lar: 02'de sql-password'ü, 05'te backoffice-client-secret'ı user secrets'tan okuduk. CI makinesinde user secrets yok; değerleri AppHost'a argüman olarak veriyoruz, configuration sistemi Parameters:... anahtarlarını oradan okuyor. Değerler her koşuda yeni üretiliyor ve test, Keycloak'tan token alırken aynı secret'ı kullanabiliyor. İkincisi bekleme: StartAsync resource'ların başladığını söyler, hazır olduklarını söylemez. Bunun için App.ResourceNotifications.WaitForResourceHealthyAsync("catalog", ct) var; health check'i olmayan bir worker için WaitForResourceAsync("inventory", KnownResourceStates.Running, ct). Üçüncüsü timeout: bütün başlatma tek bir CancellationTokenSource'a bağlı. Bir container image'ı çekilemezse test sonsuza kadar asılı kalmıyor, 3 dakikada düşüyor.

Testlerde servislere App.CreateHttpClient("gateway") ile gidiyoruz: bu client resource'un o koşudaki gerçek adresini biliyor. Testing builder port'ları varsayılan olarak rastgele seçiyor, böylece aynı makinede iki test koşusu ya da açık bir geliştirme AppHost'u çakışmıyor (kapatmak için DcpPublisher:RandomizePorts=false argümanı var).

Test, Geliştiricinin Veritabanına Dokunmamalı

AppHost'umuz geliştirme için yazıldı: SQL Server container'ının adı sabit (aspire-sql), ömrü Persistent, verisi aspire-sql-data volume'unda. Testte bunların üçü de sorun. Kalıcı container AppHost kapanınca da çalışmaya devam ediyor; aynı ad ve volume ile test, geliştiricinin veritabanına yazar. İlk denememizde annotation'ları elle silmeye çalıştık ve container'ın testten sonra hâlâ çalıştığını docker ps'te gördük. Builder API'leri aynı annotation'ı değiştirerek bunu güvenli yapıyor:

// Test geliştiricinin veritabanına dokunmasın: ayrı container adı, session ömrü, volume yok
foreach (ContainerResource container in builder.Resources.OfType<ContainerResource>().ToList())
{
    builder.CreateResourceBuilder(container)
        .WithContainerName($"aspire-test-{container.Name}")
        .WithLifetime(ContainerLifetime.Session);
    foreach (ContainerMountAnnotation volume in container.Annotations.OfType<ContainerMountAnnotation>()
                 .Where(m => m.Type == ContainerMountType.Volume).ToList())
    {
        container.Annotations.Remove(volume);
    }
}

// Testler için küçük bir katalog yeter; 200.000 kitap seed etmek gerekmez
builder.CreateResourceBuilder<ProjectResource>("migrations").WithEnvironment("Seed__GeneratedBooks", "0");

CreateResourceBuilder, AppHost'un eklediği bir resource'a testten yeniden ulaşmanın yolu; WithEnvironment ile bir service'in değişkenini ezebiliyorsun (sonra eklenen kazanıyor). Migration worker'a 04'te verdiğimiz 200.000 kitaplık seed'i burada sıfırlıyoruz; test verisi testin kendisinde.

Her Senaryo Kendi Dilimini Başlatsın

Bookstore'un AppHost'unda artık dört container var: SQL Server, Redis, RabbitMQ ve Keycloak. Sipariş senaryosunun Keycloak'a, kitap ekleme senaryosunun RabbitMQ'ya ihtiyacı yok. Dokümanda bunun için iki yol var: AppHost'ta configuration'a bağlı koşullu resource'lar ya da WithExplicitStart. AppHost'u testler için değiştirmemek adına ikincisini testten uyguladık; fixture'a verilen adlar başlatılmıyor:

foreach (string name in notNeeded)
{
    builder.CreateResourceBuilder<IResource>(name).WithExplicitStart();
}
public sealed class OrderingFixture() : BookstoreAppFixture("redis", "keycloak", "web", "web-installer")
{
    protected override async Task WaitUntilReadyAsync(DistributedApplication app, CancellationToken ct)
    {
        await app.ResourceNotifications.WaitForResourceHealthyAsync("ordering", ct);
        await app.ResourceNotifications.WaitForResourceAsync("inventory", KnownResourceStates.Running, ct);
        await app.ResourceNotifications.WaitForResourceHealthyAsync("gateway", ct);
    }
}

Angular uygulaması (web) ve onun npm install'ını yapan web-installer iki senaryoda da başlamıyor; bu testler API'leri doğruluyor, arayüz testi Integration ve E2E Test serisinde Playwright'la yapılıyor. Koşu sırasında beş saniyede bir docker ps'e baktık; aynı anda en fazla iki container vardı:

aspire-test-sql aspire-test-messaging
aspire-test-sql aspire-test-keycloak

Bir şart daha: xUnit farklı test class'larını varsayılan olarak paralel çalıştırıyor. Her fixture kendi container'larını açtığı için paralellikte iki SQL Server aynı anda ayağa kalkar. Bunu bilerek kapattık:

// Her fixture kendi container'larını başlatıyor; class'ları paralel koşmak onları ikiye katlar
[assembly: CollectionBehavior(DisableTestParallelization = true)]

"Kitap Ekle, Listede Gör" ve 409'un Sebebi

[Fact]
public async Task AddedBookAppearsInTheList()
{
    using HttpClient gateway = fixture.App.CreateHttpClient("gateway");
    gateway.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", await GetBackofficeTokenAsync());

    using HttpResponseMessage created = await gateway.PostAsJsonAsync("/api/books/",
        new { isbn = "9780140449136", title = "Crime and Punishment", author = "Fyodor Dostoevsky", price = 310.00m });
    Assert.Equal(HttpStatusCode.Created, created.StatusCode);

    List<BookDto>? page = await gateway.GetFromJsonAsync<List<BookDto>>("/api/books?page=1");
    Assert.Contains(page!, b => b.Isbn == "9780140449136");
}

GetBackofficeTokenAsync, App.CreateHttpClient("keycloak", "http") ile Keycloak'ın token endpoint'ine client_credentials isteği atıyor ve fixture'ın ürettiği secret'ı kullanıyor. İstekler gerçek uygulamada olduğu gibi gateway'den geçiyor.

Önce Tahmin Et'teki 409'a dönelim. Template'in önerdiği AddStandardResilienceHandler, deneme başına 10 saniyelik bir timeout ve retry içeriyor ve HTTP method'una bakmadan POST'u da tekrar ediyor. İlk POST 10 saniyede cevap vermeyince handler isteği kesti ve aynı POST'u yeniden gönderdi. Sunucu ise ilk isteği işlemeye devam etmiş ve kitabı kaydetmişti; ikinci deneme 409 aldı. 13 saniye de bu: 10 saniye bekleme, kısa bir backoff, hızlı bir 409. Retry'ı POST için kapatınca (options.Retry.DisableForUnsafeHttpMethods()) hata dürüst hâline döndü:

Polly.Timeout.TimeoutRejectedException : The operation didn't complete within the allowed timeout of '00:00:10'.

Asıl soru şu: boş bir veritabanına tek satır eklemek neden 10 saniyeden uzun sürüyor? Bu senaryoda Redis'i başlatmadık, ama Catalog'un Redis referansı duruyor. Redis'in adresi, 01'de tanıdığımız DCP (Developer Control Plane; Aspire'ın lokal orchestration motoru) proxy'sinin port'u; arkasında container olmasa da proxy bağlantıyı kabul ediyor ve cevap vermiyor. Kitap eklenince Catalog listenin cache'ini temizlemeye çalışıyor ve StackExchange.Redis bağlantı kurmayı bekliyor. 05'teki FailFast burada işe yaramıyor: bağlantı hiç kurulmamış, log'daki hata da client'ın hâlâ bağlanmaya çalıştığını (state: ConnectedEstablishing) gösteriyor. Aynı durumu geliştirme AppHost'unda Redis'i başlatmadan ölçtük: ilk POST 17,96 saniye, sonraki 4,99 saniye, log'da da 5 saniye arayla Could not evict the book list cache uyarıları.

Test için Redis'i de başlatabilirdik. Ama bu senaryonun konusu cache değil, ve Catalog'un Redis olmadan da çalışabilmesi zaten 05'teki kararımızdı. Kararı koda taşıdık: Redis connection string'i yoksa Catalog output cache'i bellekte tutuyor.

// Cache zorunlu değil: Redis yokken de katalog cevap vermeli, hem de hızlı
if (string.IsNullOrEmpty(builder.Configuration.GetConnectionString("redis")))
{
    // Redis tanımlı değil (test ya da katalog tek başına): cache bellekte kalsın
    builder.Services.AddOutputCache();
}
else
{
    builder.AddRedisOutputCache("redis",
        settings => settings.DisableHealthChecks = true,
        options => options.BacklogPolicy = BacklogPolicy.FailFast);
}
public sealed class CatalogFixture() : BookstoreAppFixture("redis", "messaging", "ordering", "inventory", "web", "web-installer")
{
    // Redis başlatılmıyor ama adresi yine DCP proxy'si olurdu: bağlantıyı kabul eder, hiç cevap vermez.
    // Boş connection string, katalogu bellekteki output cache'e geçiriyor.
    protected override void Configure(IDistributedApplicationTestingBuilder builder) =>
        builder.CreateResourceBuilder<ProjectResource>("catalog").WithEnvironment("ConnectionStrings__redis", "");
    ...
}

Bu değişiklikle eviction da gerçekten çalışıyor: bellekteki cache'te books tag'i temizleniyor, liste yeni kitabı hemen gösteriyor. Template'in resilience handler'ını ise tamamen kaldırdık. Fixture zaten resource'ların healthy olmasını bekliyor; sonrasında bir isteği tekrar etmek hatayı gizlemekten başka bir işe yaramıyor, POST'u tekrar etmek ise testin kendisini yanlış yapıyor.

"Sipariş Ver, Confirmed Olsun" ve Task.Delay

Sipariş akışı asenkron: POST cevabı Pending dönüyor, onay RabbitMQ üzerinden birkaç milisaniye sonra geliyor. İlk akla gelen yazım şu:

await Task.Delay(TimeSpan.FromSeconds(1));
OrderDto? order = await gateway.GetFromJsonAsync<OrderDto>($"/api/orders/{placed.Id}");
Assert.Equal("Confirmed", order!.Status);

Bu test iki şekilde kötü. Lokalde onay birkaç milisaniyede geldiği için her koşuda 1 saniye boşa harcanıyor; yüklü bir CI makinesinde onay 1 saniyeden geç gelirse test rastgele düşüyor. Süreyi büyütmek ikinci sorunu azaltıp birincisini büyütüyor. Doğrusu, koşul sağlanana kadar kısa aralıklarla tekrar sormak ve bir üst sınırda anlamlı bir mesajla düşmek:

// Koşul sağlanana kadar yeniden sorar; asılı kalmak yerine son değerle birlikte düşer
public static async Task<T> UntilAsync<T>(Func<CancellationToken, Task<T>> probe, Func<T, bool> condition, TimeSpan timeout)
{
    using CancellationTokenSource cts = new(timeout);
    T last = await probe(cts.Token);
    while (!condition(last))
    {
        try
        {
            await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
            last = await probe(cts.Token);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            Assert.Fail($"Condition not met within {timeout.TotalSeconds} s. Last value: {last}");
        }
    }

    return last;
}
[Fact]
public async Task PlacedOrderIsConfirmedAfterStockIsReserved()
{
    using HttpClient gateway = fixture.App.CreateHttpClient("gateway");

    using HttpResponseMessage response = await gateway.PostAsJsonAsync("/api/orders/", new
    {
        customerEmail = "ada@example.com",
        items = new[] { new { isbn = "9780441172719", quantity = 1 }, new { isbn = "9780547928227", quantity = 2 } }
    });
    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    OrderDto? placed = await response.Content.ReadFromJsonAsync<OrderDto>();
    Assert.Equal("Pending", placed!.Status);
    Assert.Equal(1100.00m, placed.Total);

    // Onay RabbitMQ'dan geliyor: sabit bir süre uyumak yerine yeniden sor
    OrderDto confirmed = await Polling.UntilAsync(
        async ct => (await gateway.GetFromJsonAsync<OrderDto>($"/api/orders/{placed.Id}", ct))!,
        order => order.Status == "Confirmed",
        TimeSpan.FromSeconds(10));

    Assert.Equal("Confirmed", confirmed.Status);
}

Pending assertion'ı da bilinçli: cevabın asenkron akıştan önce döndüğünü doğruluyor. Bir gün Ordering onayı senkron beklemeye başlarsa bu satır düşer ve tasarım değişikliği fark edilir. Son koşunun çıktısı:

dotnet test tests/Bookstore.AppHost.Tests --logger "console;verbosity=normal"

  Passed Bookstore.AppHost.Tests.OrderingTests.PlacedOrderIsConfirmedAfterStockIsReserved [346 ms]
  Passed Bookstore.AppHost.Tests.CatalogTests.AddedBookAppearsInTheList [433 ms]
  Passed Bookstore.AppHost.Tests.CatalogTests.AddingABookWithoutATokenIsRejected [2 ms]
Total tests: 3
     Passed: 3
 Total time: 2.0546 Minutes

Hangi Test Seviyesine Uyar?

Üç testin kendisi bir saniyeden kısa; iki dakikanın neredeyse tamamı container'ların, SQL Server'ın, migration'ın ve Keycloak'ın açılması. Bir AppHost testi bu yüzden pahalı, ve en çok sistemin parçaları arasındaki bağı doğrularken değerli: gateway route'u, service discovery, Keycloak'tan gelen token'ın Catalog'da doğrulanması, RabbitMQ üzerinden dönen onay. Bunlar tek bir service'in içinden test edilemez.

SoruUygun araç
Bir endpoint'in validation'ı, iş kuralı, hata koduWebApplicationFactory, gerekirse Testcontainers ile gerçek SQL Server
Entity Framework Core (EF Core) sorguları ve migration'larTestcontainers ile gerçek veritabanı
Service'ler, broker ve kimlik sağlayıcı birlikte doğru çalışıyor mu?Aspire.Hosting.Testing ile birkaç kritik senaryo
Kullanıcı arayüzden siparişi tamamlayabiliyor mu?Playwright ile E2E testi

WebApplicationFactory ve Testcontainers'ın ayrıntısı Integration ve E2E Test serisinde. Bir sınır daha: Aspire testi AppHost'u ve service'leri ayrı process'ler olarak çalıştırıyor. Test, bir service'in DI'ına (dependency injection; service'e bağımlılıklarını veren container) sahte bir nesne koyamıyor; davranışı yalnız environment variable, configuration ve AppHost argümanlarıyla değiştirebiliyorsun. Catalog'a boş connection string vermemiz de bu yüzden.

CI'da Çalıştırmak

Testler gerçek container başlattığı için CI makinesinde bir container runtime gerekiyor. GitHub'ın hosted Linux runner'larında Docker kurulu geliyor; job ek bir şey istemiyor:

name: AppHost tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  apphost-tests:
    # Hosted Linux runner'larda Docker var; testler gerçek container başlatıyor
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - name: Test
        run: dotnet test tests/Bookstore.AppHost.Tests --logger "console;verbosity=normal"

Workflow'u actionlint ile doğruladık ama GitHub'da çalıştırmadık; süreleri bizim makinemizdeki koşudan veriyoruz. Windows hosted runner'larında Linux container'ları çalışmadığı için bu job Linux'ta kalmalı. Parameter değerleri test içinde üretildiği için secret tanımlamaya gerek yok. Image'lar her koşuda yeniden çekiliyor; SQL Server image'ı büyük olduğu için ilk adımın süresini bu belirliyor. GitHub Actions'ın ayrıntıları (cache, artifact, runner seçimi) GitHub Actions serisinde.

Başarısız bir CI koşusunda en çok eksikliğini çektiğin şey dashboard. 13.6 ile DistributedApplicationTestingBuilderOptions.EnableDashboard ve GetDashboardUrlAsync() testte bir dashboard açıp giriş yapılmış URL'ini veriyor; sürüm notlarından aktarıyoruz, bu yazıda denemedik. CI'da daha pratik olan, resource log'larını test çıktısına yazdırmak: testing builder resource log'larını ILogger'a aktarıyor, --logger "console;verbosity=normal" ile SQL Server'ın ve service'lerin uyarıları test çıktısında görünüyor.

Üretimde Dikkat

Bu testler production'ı değil, production'a giden yolu koruyor; dikkat edilecek yer CI'ın kendisi. Test başına değil class başına AppHost kullan; senaryo sayısı arttıkça fixture'ları birleştirmeyi düşün. Her yeni fixture birkaç on saniye demek.

Timeout'ları kat kat koy: fixture başlatma için bir üst sınır (bizde 3 dakika), her asenkron bekleme için kendi sınırı (bizde 10 saniye), job için de bir üst sınır (timeout-minutes). Sınırı olmayan bir bekleme CI'da saatlerce asılı kalan bir job'a dönüşür.

Testte üretilen parolalar ve secret'lar test süreciyle birlikte yok oluyor; bunları log'a yazdırma. aspire describe çıktısında gördüğümüz gibi connection string'ler parolayı açık taşıyor ve verbose log'lar bu değerleri yakalayabiliyor.

Sık Düşülen Tuzaklar

Template'teki resilience handler'ı düşünmeden açık bırakmak. POST'u tekrar ediyor ve 409 gibi, testin değil retry'ın ürettiği hatalar doğuruyor.

Test AppHost'unun geliştirme verisini kullanması. Kalıcı container ve volume testte de kalıcı; container adını, ömrünü ve volume'u testte değiştir.

Başlatılmayan bir resource'un bedava olduğunu sanmak. Referans duruyor ve adres, cevap vermeyen bir proxy'yi gösteriyor; client'ın timeout'u kadar bekliyorsun.

Asenkron sonucu Task.Delay ile beklemek. Ya yavaş ya rastgele düşen bir test elde edersin; koşulu sorgula ve üst sınır koy.

Her şeyi AppHost testiyle test etmek. Bir validation kuralı için iki dakikalık sistem başlatmak, test setini kimsenin çalıştırmak istemediği bir şeye çeviriyor.

Küçük Bir Deneme

★☆☆ Fixture'a template'teki AddStandardResilienceHandler'ı geri ekle ve Catalog'daki boş connection string satırını kaldır. 409'u kendi makinende de görüyor musun? Test kaç saniye sürüyor?

★★☆ DisableTestParallelization satırını kaldır ve testleri çalıştır. Aynı anda kaç container açılıyor, toplam süre nasıl değişiyor?

★★★ Inventory'yi başlatmayan bir fixture ile "stok ayrılmazsa sipariş Pending kalır" testini yaz. Polling yardımcısını bu sefer koşulun hiç gerçekleşmediğini doğrulamak için nasıl kullanırsın; o testin süresini neyin belirlediğine dikkat et.

Sistemi testte ayağa kaldırmayı da gördük; geriye onu makinemizin dışına çıkarmak kaldı. Bir sonraki yazıda aspire publish, aspire deploy ve aspire do ile aynı AppHost'tan Docker Compose dosyası, Kubernetes için Helm chart'ı ve Azure çıktıları üretiyoruz; 02'de söz verdiğimiz parameter yer tutucularını ve WithExternalHttpEndpoints'in deploy çıktısındaki karşılığını orada göreceğiz.