Message Queue Kodunu Test Etmek: Testcontainers, MassTransit Test Harness ve Contract Testleri
Mantığı broker'dan ayırıp unit test et, broker davranışını Testcontainers ile gerçek RabbitMQ'da dene, beklemeyi Task.Delay ile değil sinyal ve sınırlı polling ile çöz. MassTransit test harness'ı, negatif assert'in hareketsizlik maliyeti, Total→TotalAmount değişikliğinin varsayılan JSON ayarlarıyla sessizce 0'a dönmesi ve .NET 10'da xUnit v3 için gereken Microsoft.Testing.Platform ayarı.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit v3 4.0.2 (Microsoft.Testing.Platform), Testcontainers.RabbitMq 4.16.0, RabbitMQ 4.4, RabbitMQ.Client 7.2.2 ve MassTransit 8.5.12 ile derlenip denenmiştir.
Önce Tahmin Et
Bir integration test, gerçek bir RabbitMQ'ya OrderPlaced yayınlıyor ve BasicPublishAsync döner dönmez, bir sonraki satırda faturanın oluştuğunu assert ediyor. Test geçer mi? Geçiyorsa, CI (Continuous Integration) sunucusunda da her seferinde geçer mi? Araya await Task.Delay(2000) koymak sorunu çözer mi, yoksa sadece yavaşlatıp seyrekleştirir mi?
Mesajlaşma kodunu test etmenin zorluğu iki yerde toplanıyor: iş mantığı broker'a gömülü olunca test için broker gerekiyor, broker devreye girince de test asenkron bir dünyada "ne zaman bitti" sorusunu cevaplamak zorunda kalıyor. Bu yazıda katmanları ayırıyoruz: broker'sız unit test, Testcontainers ile gerçek RabbitMQ'ya integration test, MassTransit'in test harness'ı ve mesaj contract'ları için ayrı testler. ASP.NET Core tarafındaki integration test altyapısı Web API (Application Programming Interface) serisinin "Integration Test" yazısında; uçtan uca test stratejisi ilerideki Integration ve E2E (End-to-End) Test serisinde.
Önce Mantığı Ayır: Handler'ı Broker'sız Test Etmek
Test edilebilirlik test yazmaya başlamadan önce, kodun şeklinde başlıyor. Billing'in mantığı broker'dan habersiz bir service'te:
public sealed class InvoiceService(IInvoiceStore store)
{
private static readonly HashSet<string> SupportedCurrencies = ["EUR", "USD", "TRY"];
public async Task<Invoice?> CreateInvoiceAsync(OrderPlaced order, CancellationToken ct = default)
{
if (!SupportedCurrencies.Contains(order.Currency))
throw new PermanentMessageException($"Unsupported currency {order.Currency}");
if (await store.ExistsForOrderAsync(order.OrderId, ct))
return null;
decimal amount = order.Items.Sum(i => i.Quantity * i.UnitPrice);
var invoice = new Invoice(Guid.CreateVersion7(), order.OrderId, amount, order.Currency);
await store.SaveAsync(invoice, ct);
return invoice;
}
}
// broker'a bağlı ince adapter: mantık InvoiceService'te, burada yalnız ack/reject kararı
public sealed class OrderPlacedRabbitHandler(InvoiceService service)
{
public async Task HandleAsync(IChannel channel, BasicDeliverEventArgs ea, CancellationToken ct = default)
{
try
{
OrderPlaced order = JsonSerializer.Deserialize<OrderPlaced>(ea.Body.Span, JsonSerializerOptions.Web)
?? throw new PermanentMessageException("Empty message body");
await service.CreateInvoiceAsync(order, ct);
await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, ct);
}
catch (Exception ex) when (ex is PermanentMessageException or JsonException)
{
await channel.BasicRejectAsync(ea.DeliveryTag, requeue: false, ct);
}
}
}
Aynı InvoiceService'i MassTransit consumer'ı da kullanıyor; iki adapter, tek mantık. Unit testler broker'ı hiç görmüyor:
public sealed class InvoiceServiceTests
{
private readonly InMemoryInvoiceStore _store = new();
[Fact]
public async Task Second_delivery_of_same_order_does_not_create_invoice()
{
OrderPlaced order = Orders.Sample();
var service = new InvoiceService(_store);
await service.CreateInvoiceAsync(order, TestContext.Current.CancellationToken);
Invoice? second = await service.CreateInvoiceAsync(order, TestContext.Current.CancellationToken);
Assert.Null(second);
}
[Fact]
public async Task Unsupported_currency_is_a_permanent_failure()
{
var service = new InvoiceService(_store);
await Assert.ThrowsAsync<PermanentMessageException>(() =>
service.CreateInvoiceAsync(Orders.Sample("XXX"), TestContext.Current.CancellationToken));
}
}
Bu testler milisaniyeler içinde biter ve serinin önceki yazılarındaki iki kritik kararı kilitler: aynı siparişin ikinci teslimi fatura kesmez (onuncu yazı), desteklenmeyen para birimi kalıcı hatadır ve retry edilmez (sekizinci yazı). TestContext.Current.CancellationToken xUnit v3'ün test başına iptal token'ı; xUnit'in analyzer'ı token alan çağrılara bunun verilmesini öneriyor. Fake store da bilerek basit bir class; mocking kütüphanesi gerekmiyor:
public sealed class InMemoryInvoiceStore : IInvoiceStore
{
private readonly ConcurrentDictionary<Guid, Invoice> _invoices = new();
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<Invoice>> _waiters = new();
public Task<bool> ExistsForOrderAsync(Guid orderId, CancellationToken ct) =>
Task.FromResult(_invoices.ContainsKey(orderId));
public Task SaveAsync(Invoice invoice, CancellationToken ct)
{
_invoices[invoice.OrderId] = invoice;
Waiter(invoice.OrderId).TrySetResult(invoice);
return Task.CompletedTask;
}
// Thread.Sleep yerine sinyal: fatura yazıldığı an test devam eder
public Task<Invoice> WaitForInvoiceAsync(Guid orderId, TimeSpan timeout) =>
Waiter(orderId).Task.WaitAsync(timeout);
private TaskCompletionSource<Invoice> Waiter(Guid orderId) =>
_waiters.GetOrAdd(orderId, _ => new TaskCompletionSource<Invoice>(TaskCreationOptions.RunContinuationsAsynchronously));
}
Testcontainers ile Gerçek RabbitMQ
Unit test, mesajın gerçekten queue'ya gidip gitmediğini, DLX'in (Dead Letter Exchange) doğru bağlanıp bağlanmadığını, ack'in doğru çağrılıp çağrılmadığını söylemez. Bunun için gerçek bir broker gerekiyor ve Testcontainers onu test başlarken bir Docker container'ı olarak açıp sonunda kaldırıyor:
public sealed class RabbitMqFixture : IAsyncLifetime
{
private readonly RabbitMqContainer _container = new RabbitMqBuilder("rabbitmq:4.4-management").Build();
public IConnection Connection { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
var factory = new ConnectionFactory { Uri = new Uri(_container.GetConnectionString()) };
Connection = await factory.CreateConnectionAsync();
}
public async ValueTask DisposeAsync()
{
await Connection.DisposeAsync();
await _container.DisposeAsync();
}
}
Image'ı açıkça ve sürümüyle ver: test ortamı production'daki broker sürümüyle aynı olmalı, quorum queue'nun delivery limit'i ya da 4.3'ün delayed retry'ı gibi davranışlar sürüme bağlı. Testcontainers varsayılan olarak rastgele bir host port'u seçer ve GetConnectionString() doğru adresi döndürür; aynı makinede paralel çalışan test süreçleri çakışmaz. (Bu yazının denemelerini yaptığım ortamda container adları ve port'lar kısıtlı olduğu için fixture'a WithName ve WithPortBinding ekleyip Ryuk temizlik container'ını kapattım; kendi projende buna gerek yok.) IClassFixture ile container class'taki bütün testler için bir kez açılıyor; her test kendi queue adını kullanarak diğerlerinden yalıtılıyor.
Bekleme Problemi: Sleep Yerine Sinyal
Baştaki sorunun cevabı: publish'ten hemen sonra yapılan assert çoğunlukla patlar, çünkü BasicPublishAsync broker mesajı kabul ettiğinde döner; consumer'ın mesajı alıp işlemesi ayrı bir thread'de, birkaç milisaniye sonra olur. Task.Delay(2000) testi geçirir ama iki sorun yaratır: her test 2 saniye kaybeder ve yavaş bir CI makinesinde 2 saniye yetmediği gün test "bazen" kırılır. Doğrusu, testin beklediği şeyin kendisini sinyal yapmak:
public sealed class BillingRabbitMqTests(RabbitMqFixture rabbit) : IClassFixture<RabbitMqFixture>
{
private readonly InMemoryInvoiceStore _store = new();
[Fact]
public async Task Published_order_produces_an_invoice()
{
CancellationToken ct = TestContext.Current.CancellationToken;
await using IChannel channel = await StartBillingAsync("billing-it-1", ct);
OrderPlaced order = Orders.Sample();
await channel.BasicPublishAsync("bookstore.ordering", "order.placed", mandatory: true,
new BasicProperties { MessageId = Guid.NewGuid().ToString(), Persistent = true },
JsonSerializer.SerializeToUtf8Bytes(order, JsonSerializerOptions.Web), ct);
Invoice invoice = await _store.WaitForInvoiceAsync(order.OrderId, TimeSpan.FromSeconds(10));
Assert.Equal(50.00m, invoice.Amount);
}
[Fact]
public async Task Malformed_message_is_dead_lettered()
{
CancellationToken ct = TestContext.Current.CancellationToken;
await using IChannel channel = await StartBillingAsync("billing-it-2", ct);
await channel.BasicPublishAsync("bookstore.ordering", "order.placed", mandatory: true,
new BasicProperties { MessageId = "poison-1", Persistent = true },
Encoding.UTF8.GetBytes("{ not json"), ct);
BasicGetResult dead = await Eventually.GetAsync(
() => channel.BasicGetAsync("billing-it-2.dlq", autoAck: true, ct), TimeSpan.FromSeconds(10));
Assert.Equal("poison-1", dead.BasicProperties.MessageId);
}
}
StartBillingAsync, queue'yu DLX'iyle birlikte declare eden, OrderPlacedRabbitHandler'ı bir AsyncEventingBasicConsumer'a bağlayan ve channel'ı döndüren 30 satırlık bir yardımcı; production'daki topology koduyla aynı argümanları kullanıyor. İlk test, fake store'daki TaskCompletionSource'u bekliyor: fatura yazıldığı an test devam ediyor, 10 saniye yalnız üst sınır. İkinci testte bekleyebileceğimiz bir sinyal yok (DLQ'ya, yani Dead-Letter Queue'ya düşen mesaj için uygulamada bir callback yok), bu yüzden sınırlı ve kısa aralıklı bir polling kullanıyoruz:
public static class Eventually
{
// sınırlı ve kısa aralıklı polling: sabit Task.Delay(5000) yerine
public static async Task<T> GetAsync<T>(Func<Task<T?>> probe, TimeSpan timeout) where T : class
{
using var cts = new CancellationTokenSource(timeout);
while (true)
{
if (await probe() is { } value)
return value;
await Task.Delay(100, cts.Token);
}
}
}
Koşul 100 ms içinde sağlanırsa test 100 ms sürer; hiç sağlanmazsa 10 saniye sonra TaskCanceledException ile anlamlı bir şekilde düşer. Sabit bir Delay ise her iki durumda da tam süreyi harcar.
MassTransit Test Harness
MassTransit consumer'ı için broker'ı container'la açmak gerekmiyor: test harness, in-memory transport üzerinde bütün bus'ı çalıştırır ve gönderilen, yayınlanan ve tüketilen mesajları kaydeder:
public sealed class OrderPlacedConsumerTests
{
[Fact]
public async Task Consumer_publishes_InvoiceCreated()
{
await using ServiceProvider provider = BuildProvider();
ITestHarness harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
CancellationToken ct = TestContext.Current.CancellationToken;
OrderPlaced order = Orders.Sample();
await harness.Bus.Publish(order, ct);
// Any: mesaj gelene ya da bus belirli bir süre boşta kalana kadar bekler; polling yazmazsın
IConsumerTestHarness<OrderPlacedConsumer> consumer = harness.GetConsumerHarness<OrderPlacedConsumer>();
Assert.True(await consumer.Consumed.Any<OrderPlaced>(x => x.Context.Message.OrderId == order.OrderId, ct));
Assert.True(await harness.Published.Any<InvoiceCreated>(x => x.Context.Message.OrderId == order.OrderId, ct));
}
[Fact]
public async Task Unsupported_currency_publishes_a_fault()
{
await using ServiceProvider provider = BuildProvider();
ITestHarness harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
CancellationToken ct = TestContext.Current.CancellationToken;
await harness.Bus.Publish(Orders.Sample("XXX"), ct);
Assert.True(await harness.Published.Any<Fault<OrderPlaced>>(ct));
Assert.False(await harness.Published.Any<InvoiceCreated>(ct));
}
private static ServiceProvider BuildProvider() => new ServiceCollection()
.AddSingleton<IInvoiceStore, InMemoryInvoiceStore>()
.AddScoped<InvoiceService>()
.AddMassTransitTestHarness(x => x.AddConsumer<OrderPlacedConsumer>())
.BuildServiceProvider(validateScopes: true);
}
Consumed.Any ve Published.Any, beklenen mesaj kaydedilene ya da bus bir süre boşta kalana kadar bekler; bekleme problemi harness'ın içinde çözülmüş. Bunun bir yan etkisi var: "bu mesaj yayınlanmadı" diyen negatif bir assert, mesajın gelmeyeceğinden emin olmak için hareketsizlik süresinin dolmasını bekler. MassTransit dokümanına göre bu süre (TestInactivityTimeout) varsayılan olarak 1,2 saniye ve aşağıdaki çıktıda fault testinin neden 1,4 saniye sürdüğünü açıklıyor. Dokümana göre v9.1 ile gelen harness.Act(...) bu bekleme sürelerini ortadan kaldırıyor. İkinci test ayrıca sekizinci ve dokuzuncu yazılardaki fault yolunu da kilitliyor: desteklenmeyen para birimi bir Fault<OrderPlaced> üretir ve fatura event'i yayınlanmaz.
Contract Testleri: v1 Mesajını v2 Okuyabilir mi?
Serinin altıncı yazısında OrderPlaced'ı v2'ye taşıdık: sona opsiyonel CustomerEmail eklendi. Bu kararın bozulmadığını bir test kanıtlamalı; production'daki v1 producer'ın ürettiği JSON (JavaScript Object Notation) teste sabit bir örnek olarak giriyor:
// v2: sona eklenmiş opsiyonel alan (additive değişiklik)
public sealed record OrderPlacedV2(
Guid OrderId, Guid CustomerId, decimal Total, string Currency, DateTimeOffset PlacedAt,
IReadOnlyList<OrderPlacedItem> Items, string? CustomerEmail = null);
// kırıcı değişiklik: Total -> TotalAmount
public sealed record OrderPlacedRenamed(
Guid OrderId, Guid CustomerId, decimal TotalAmount, string Currency, DateTimeOffset PlacedAt,
IReadOnlyList<OrderPlacedItem> Items);
public sealed class ContractTests
{
// v1 producer'ın gerçekten ürettiği JSON, dosyada sabitlenmiş örnek
private const string V1Json = """
{"orderId":"6c1f0e7a-2b8e-4a53-9d1e-3f8a7b6c5d4e","customerId":"0d9b2c1a-7e6f-4a5b-8c3d-2e1f0a9b8c7d",
"total":50.00,"currency":"EUR","placedAt":"2026-11-08T10:15:00+00:00",
"items":[{"bookId":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","quantity":2,"unitPrice":25.00}]}
""";
private static readonly JsonSerializerOptions Strict = new(JsonSerializerDefaults.Web)
{
RespectRequiredConstructorParameters = true
};
[Fact]
public void V2_consumer_reads_v1_message()
{
OrderPlacedV2 message = JsonSerializer.Deserialize<OrderPlacedV2>(V1Json, Strict)!;
Assert.Equal(50.00m, message.Total);
Assert.Null(message.CustomerEmail);
}
[Fact]
public void Rename_is_silent_with_default_options()
{
OrderPlacedRenamed message = JsonSerializer.Deserialize<OrderPlacedRenamed>(V1Json, JsonSerializerOptions.Web)!;
Assert.Equal(0m, message.TotalAmount);
}
[Fact]
public void Rename_is_caught_with_strict_options()
{
JsonException ex = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<OrderPlacedRenamed>(V1Json, Strict));
Assert.Contains("totalAmount", ex.Message);
}
}
Üç test üç farklı şeyi kilitliyor. v2 consumer, v1 mesajını okuyabiliyor ve yeni alan null geliyor: additive değişiklik güvenli. Tersi de test ediliyor (yukarıda göstermediğim V1_consumer_reads_v2_message): System.Text.Json tanımadığı property'leri varsayılan olarak yok sayıyor, yani eski consumer yeni mesajda boğulmuyor. Asıl ders üçüncüde: Total'ı TotalAmount yapan kırıcı değişiklik varsayılan ayarlarla hiçbir hata vermiyor, TotalAmount sessizce 0 oluyor. Bir fatura servisinin 0 tutarlı fatura kesmesi, exception'dan çok daha pahalı bir hata. .NET 9 ile gelen RespectRequiredConstructorParameters açıkken, constructor'da varsayılan değeri olmayan bir parametre JSON'da yoksa deserialization başarısız oluyor. Aynı JSON ve ayarlarla küçük bir konsol denemesinde exception mesajı:
JSON deserialization for type 'OrderPlacedRenamed' was missing required properties including: 'totalAmount'.
Bu ayarı consumer'ın kendi deserialization'ında da kullanmak, kırıcı bir değişikliği sessiz veri bozulması yerine DLQ'ya düşen ve alarm çaldıran bir hataya çevirir. Contract testleri production'daki gerçek örneklerle beslemek en güvenlisi: producer'ın son sürümünün ürettiği bir mesajı dosya olarak saklayıp consumer testlerinde okumak, "kağıt üzerinde uyumlu" ile "gerçekte uyumlu" arasındaki farkı kapatır. Kafka tarafında Schema Registry'nin compatibility kontrolü aynı işi şema seviyesinde yapar; o konu serinin altıncı yazısında.
Testleri Çalıştırmak
Test projesi xUnit v3 kullanıyor ve bir executable olarak derleniyor:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageReference Include="Testcontainers.RabbitMq" Version="4.16.0" />
<PackageReference Include="MassTransit" Version="8.5.12" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Bookstore.Billing/Bookstore.Billing.csproj" />
</ItemGroup>
</Project>
.NET 10 SDK'sında (Software Development Kit) dotnet test'i ilk çalıştırdığımda şu hatayı aldım:
error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
xUnit v3'ün 4.x sürümleri Microsoft.Testing.Platform üzerinde çalışıyor ve .NET 10'da dotnet test'in bu platformu kullanacağını global.json'da açıkça söylemek gerekiyor:
{
"sdk": { "version": "10.0.402", "rollForward": "latestFeature" },
"test": { "runner": "Microsoft.Testing.Platform" }
}
Bundan sonra dotnet test --project Bookstore.Billing.Tests --output Detailed (test satırlarını ve özetin bir kısmını bıraktım):
passed Bookstore.Billing.Tests.InvoiceServiceTests.Unsupported_currency_is_a_permanent_failure (8ms)
passed Bookstore.Billing.Tests.InvoiceServiceTests.Second_delivery_of_same_order_does_not_create_invoice (1ms)
passed Bookstore.Billing.Tests.InvoiceServiceTests.Creates_invoice_with_item_total (2ms)
passed Bookstore.Billing.Tests.ContractTests.Rename_is_silent_with_default_options (18ms)
passed Bookstore.Billing.Tests.ContractTests.V2_consumer_reads_v1_message (0ms)
passed Bookstore.Billing.Tests.ContractTests.V1_consumer_reads_v2_message (2ms)
passed Bookstore.Billing.Tests.ContractTests.Rename_is_caught_with_strict_options (0ms)
passed Bookstore.Billing.Tests.OrderPlacedConsumerTests.Unsupported_currency_publishes_a_fault (1s 381ms)
passed Bookstore.Billing.Tests.OrderPlacedConsumerTests.Consumer_publishes_InvoiceCreated (12ms)
passed Bookstore.Billing.Tests.BillingRabbitMqTests.Published_order_produces_an_invoice (128ms)
passed Bookstore.Billing.Tests.BillingRabbitMqTests.Malformed_message_is_dead_lettered (81ms)
Bookstore.Billing.Tests.dll (net10.0|arm64) passed (3s 243ms)
Test run summary: Passed!
total: 11
duration: 3s 428ms
Gerçek RabbitMQ'ya giden iki test 128 ve 81 ms sürdü; sinyal ve kısa polling sayesinde bekleme yok. Toplam sürenin büyük kısmı container'ın açılması (fixture, test sürelerine dahil değil). En yavaş test, broker kullanmayan harness testi: 1,4 saniyenin neredeyse tamamı negatif assert'in hareketsizlik beklemesi.
Üretimde Dikkat
CI'da Docker olmalı. Testcontainers bir Docker daemon'u (ya da uyumlu bir runtime) bekler. GitHub Actions'ın Linux runner'larında hazır gelir; bazı kurumsal agent'larda yoktur ve test "bağlanamadı" diye düşer. Image'ları önceden çekmek ve cache'lemek, ilk testin dakikalarca beklemesini önler.
Container'ı paylaş, queue'yu paylaşma. Her test için yeni container açmak pahalıdır; class ya da collection başına bir container yeterli. Ama her test kendi queue ve exchange adlarını kullanmalı; aynı queue'yu dinleyen iki test birbirinin mesajını tüketir ve rastgele kırılır.
Sürümü sabitle. rabbitmq:latest ile yazılmış bir test, image güncellendiği gün davranış değiştirebilir. Testteki broker sürümü production'daki ile aynı olsun ve birlikte güncellensin.
Testler topology'nin belgesidir. DLQ'ya düşme testi, DLX argümanlarının doğru olduğunu her build'de kanıtlar. Topology'yi production kodu ile test arasında kopyalamak yerine aynı declare kodunu çağırmak, ikisinin ayrışmasını önler.
Sık Düşülen Tuzaklar
Thread.Sleep ya da sabit Task.Delay ile beklemek. Hem yavaş hem kararsız; beklenen sonucun kendisini sinyal yap ya da sınırlı polling kullan.
IChannel'ı mock'lamak. Mock'lanmış bir channel'la geçen test, DLX argümanı yanlış yazıldığında da geçer. Broker davranışını test etmek istiyorsan gerçek broker kullan, mantığı test etmek istiyorsan broker'ı tamamen dışarıda bırak.
Queue derinliğini publish'ten hemen sonra assert etmek. Mesaj henüz yolda olabilir ya da consumer onu çoktan almış olabilir; sayı her iki durumda da yanıltıcıdır.
Negatif assert'in maliyetini görmezden gelmek. "Yayınlanmadı" diyen her harness assert'i hareketsizlik süresini bekler; çok sayıda negatif assert test süresini saniyelerle büyütür.
JSON'un varsayılan toleransına güvenmek. Varsayılan ayarlar yeniden adlandırılan bir alanı sessizce varsayılan değere çevirir; contract testini sıkı ayarlarla yaz.
Küçük Bir Deneme
★☆☆ BillingRabbitMqTests'e, aynı OrderPlaced'ı iki kez yayınlayıp yalnız bir fatura oluştuğunu doğrulayan bir test ekle. Fake store'un ExistsForOrderAsync'i race'e açık mı? Testi 50 kez çalıştır ve sonucu yorumla.
★★☆ Sekizinci yazıdaki TTL (Time-To-Live) + DLX retry topology'si için bir integration test yaz: geçici hatayla iki kez düşen mesajın üçüncü denemede başarılı olduğunu ve DLQ'nun boş kaldığını Eventually ile doğrula. Bekleme sürelerini testte kısaltmak için seviyeleri parametre olarak ver.
★★★ Testcontainers.Kafka ile sekizinci yazıdaki BillingRetryRouter'ı test et: her zaman 503 dönen bir handler'la mesajın DLT'ye (dead-letter topic) retry-attempt: 3 ve doğru original-topic-partition-offset header'larıyla düştüğünü doğrula.
Testler yerinde olunca bir sonraki adım korkutucu olmaktan çıkıyor: sürüm yükseltmek. Serinin son yazısında RabbitMQ.Client 6'dan 7'ye ve MassTransit 8'den 9'a geçişi, gerçek derleme hataları ve çalışma zamanı sürprizleriyle ele alıyoruz.