WebApplicationFactory Derinlemesine: Custom Factory, ConfigureTestServices, Configuration ve Test Authentication
Test configuration'ını UseSetting veya CreateHost ile ver; ConfigureAppConfiguration'la verilen değer Program.cs'in Build öncesinde okuduğu yere ulaşmaz. Custom factory'de service değiştirmeyi, header tabanlı test authentication handler'ıyla 401 ve 403'ü ayırmayı, redirect ve ProblemDetails helper'larını, WithWebHostBuilder'ın gerçek maliyetini ve testte istemeden çalışan background service'leri ölçümlerle gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3, Microsoft.AspNetCore.Mvc.Testing 10.0.15 ve Testcontainers for .NET 4.16 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un ödeme entegrasyonunu test etmek istiyorsun. Plan basit: 4. yazıda kuracağımız sahte ödeme sunucusu http://localhost:9091/ adresinde dinleyecek, testte Payments:BaseUrl ayarını bu adrese çevireceksin. Ekipten biri Program.cs'te typed client'ı şöyle kaydetmiş:
Uri paymentsBaseUrl = new(builder.Configuration["Payments:BaseUrl"]!);
builder.Services.AddHttpClient<PaymentClient>(client => client.BaseAddress = paymentsBaseUrl)
.AddStandardResilienceHandler(builder.Configuration.GetSection("Payments:Resilience"));
Ayarı test factory'sine vermenin dört yolunu denedim. Her factory ayrı bir class, hepsi aynı değeri veriyor:
public sealed class UseSettingFactory : ProbeFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseSetting("Payments:BaseUrl", FakeUrl);
}
}
public sealed class AppConfigurationFactory : ProbeFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration(config =>
config.AddInMemoryCollection(new Dictionary<string, string?> { ["Payments:BaseUrl"] = FakeUrl }));
}
}
public sealed class HostConfigurationFactory : ProbeFactory
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(config =>
config.AddInMemoryCollection(new Dictionary<string, string?> { ["Payments:BaseUrl"] = FakeUrl }));
return base.CreateHost(builder);
}
}
// Dördüncüsü: factory'yi oluşturmadan önce ortam değişkeni
Environment.SetEnvironmentVariable("Payments__BaseUrl", ProbeFactory.FakeUrl);
ProbeFactory, host ayağa kalktıktan sonra iki şeye bakıyor: IHttpClientFactory'nin PaymentClient için ürettiği HttpClient'ın BaseAddress'i ve IOptions<PaymentOptions> içindeki değer. Dört yöntemin dördü de options'ta sahte adresi gösterir mi? Peki Program.cs'te erken okunan paymentsBaseUrl hangilerinde sahte adres olur?
Cevap: Configuration Program.cs'e Ne Zaman Ulaşır?
Deneyin gerçek çıktısı:
UseSettingFactory: eager=http://localhost:9091/ options=http://localhost:9091/
AppConfigurationFactory: eager=https://api.payments.example/ options=http://localhost:9091/
HostConfigurationFactory: eager=http://localhost:9091/ options=http://localhost:9091/
EnvironmentVariable: eager=http://localhost:9091/ options=http://localhost:9091/
Options dört durumda da doğru adresi gördü. Ama ConfigureAppConfiguration ile verilen değer, Program.cs'in builder.Build() öncesinde okuduğu yere ulaşmadı: typed client, appsettings.json'daki gerçek ödeme adresine gidecek şekilde kuruldu. Test yeşil kalsa bile bu, testin production ödeme sağlayıcısına istek atmaya çalışması demek.
| Yöntem | Build öncesi okuma | Options / Build sonrası | Not |
|---|---|---|---|
UseSetting | görüyor | görüyor | tek tek key/value için en sade yol |
ConfigureAppConfiguration | görmüyor | görüyor | source korunur, ama Build'den sonra eklenir |
CreateHost + ConfigureHostConfiguration | görüyor | görüyor | değerler args ile gelir |
| Ortam değişkeni | görüyor | görüyor | process geneli; paralel testlerde tehlikeli |
Microsoft Learn'deki integration test sayfası bu iki yolu açıkça ayırıyor. ConfigureAppConfiguration ile eklenen source korunuyor ama yalnız uygulama build edildikten sonra kullanılabiliyor. CreateHost içinde ConfigureHostConfiguration ile verilen değerler ise entry point çalışmadan önce okunup args olarak aktarılıyor; bu yüzden Program.cs'in WebApplication.CreateBuilder(args) çağrısında args'ı geçirmesi şart. args verilmezse bu ayarlar hiç etkili olmuyor. Bookstore'un Program.cs'i args'ı geçiriyor.
Ortam değişkeni de çalışıyor ama process genelinde bir ayar. xUnit v3 farklı test class'larını paralel koşturuyor; bir class ortam değişkenini değiştirirken başka bir class'ın factory'si aynı anda ayağa kalkıyorsa hangi değeri göreceği belli değil. Factory'ye özel UseSetting bu yüzden daha güvenli.
Asıl ders ise Program.cs tarafında. Configuration'ı Build() öncesinde bir local değişkene okumak, onu kim ne zaman değiştirirse değiştirsin donduruyor. Bookstore'un gerçek Program.cs'i adresi options'tan ve service provider üzerinden, client oluşturulduğu anda okuyor:
builder.Services.AddOptions<PaymentOptions>().BindConfiguration("Payments");
builder.Services.AddHttpClient<PaymentClient>((sp, client) =>
{
// Adres client oluşturulurken okunur; testin verdiği configuration'ı görür
PaymentOptions payments = sp.GetRequiredService<IOptions<PaymentOptions>>().Value;
client.BaseAddress = payments.BaseUrl;
client.DefaultRequestHeaders.Add("X-Api-Key", payments.ApiKey);
})
.AddStandardResilienceHandler(builder.Configuration.GetSection("Payments:Resilience"));
Bu hâliyle dört yöntemin dördü de çalışıyor. .NET 11 bu tabloya factory'ye yeni bir builder hook'u ekliyor; ayrıntısı serinin son yazısında, sürümler arası farklarla birlikte.
Custom Factory'nin Son Hâli
1. yazıdaki factory yalnız connection string değiştiriyordu. Bu yazının sonunda şu hâle geliyor:
using Bookstore.Api.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bookstore.Api.IntegrationTests;
public sealed class BookstoreApiFactory(SqlServerFixture sql) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Bookstore", sql.ConnectionString);
builder.ConfigureTestServices(services =>
{
// JWT yerine test kullanıcısı; varsayılan scheme bu satırla değişir
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
// Testte istenmeyen background service'i registration'dan çıkar
ServiceDescriptor worker = services.Single(d =>
d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(LowStockReportService));
services.Remove(worker);
});
}
public HttpClient CreateClientFor(TestUser user, bool allowAutoRedirect = true)
{
HttpClient client = CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = allowAutoRedirect });
client.DefaultRequestHeaders.Add(TestAuthHandler.UserHeader, user.HeaderValue);
return client;
}
}
Üç parça var: configuration (UseEnvironment, UseSetting), service değişiklikleri (ConfigureTestServices) ve testlerin okunurluğunu artıran bir yardımcı (CreateClientFor). Ortam adını Testing yapmak, appsettings.Development.json'daki geliştirici ayarlarının teste sızmasını engelliyor; WebApplicationFactory ortamı siz değiştirmezseniz Development kabul ediyor.
Service Değiştirmek: ConfigureServices mi, ConfigureTestServices mi?
Eski blog yazılarında şu kural sık geçer: factory'deki ConfigureServices, uygulamanın kendi registration'larından önce çalışır, bu yüzden değiştirmek istediğin service'i ConfigureTestServices'te değiştir. Bu kural Startup class'ı kullanan eski hosting modelinden geliyor; Learn sayfası da o modelde test ConfigureServices'in Startup.ConfigureServices'ten önce çalıştığını belirtiyor. Minimal hosting kullanan bir .NET 10 uygulamasında durumu ölçtüm. Program'ın AddSingleton(TimeProvider.System) registration'ını ve JWT (JSON Web Token; imzalı erişim token'ı) default scheme'ini iki ayrı factory'de, biri ConfigureServices diğeri ConfigureTestServices ile değiştirdim:
ConfigureServices: TimeProvider=FakeTimeProvider, POST /api/books as admin -> 201
ConfigureTestServices: TimeProvider=FakeTimeProvider, POST /api/books as admin -> 201
İkisi de Program.cs'ten sonra çalıştı; son registration kazandı ve test kullanıcısı admin olarak kitap ekleyebildi. Aynı deneyi, factory'nin ConfigureTestServices'te bir saat verdiği ve testin WithWebHostBuilder ile başka bir saat verdiği durum için de yaptım; testin verdiği saat iki yöntemde de kazandı. Yani .NET 10'da sıra karışıklığı eskisi kadar tehlikeli değil. Yine de ConfigureTestServices'i tercih ediyorum: niyeti açık ("bunu yalnız test için değiştiriyorum") ve Startup class'lı bir projeye taşındığında da aynı anlamı koruyor.
Asıl dikkat edilmesi gereken nokta "son registration kazanır" kuralının her yerde geçerli olmaması. Tek instance isteyen bir GetRequiredService<TimeProvider>() son kaydı alır. Ama IEnumerable<T> olarak çözülen service'lerde yeni bir kayıt eskisinin yerine geçmez, yanına eklenir. Hosted service'ler tam olarak böyle çözülüyor; bu yüzden factory LowStockReportService'i yeni bir şeyle değiştirmiyor, registration'ını bulup siliyor.
Test Authentication: 401 ile 403'ü Ayırmak
Production'da Bookstore JWT bearer token bekliyor. Testte gerçek bir identity provider'dan token almak hem yavaş hem kırılgan; token doğrulamanın kendisi zaten framework'ün sorumluluğu. Test için istekteki bir header'dan kullanıcı oluşturan bir authentication handler yazıyoruz:
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
namespace Bookstore.Api.IntegrationTests.Infrastructure;
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
public const string UserHeader = "X-Test-User";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Header yoksa anonim istek: NoResult, challenge'ta 401 üretir
if (!Request.Headers.TryGetValue(UserHeader, out StringValues header))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
// Header biçimi: "<customerId>;<role>,<role>"
string[] parts = header.ToString().Split(';');
List<Claim> claims = [new("customer_id", parts[0]), new(ClaimTypes.Name, $"customer-{parts[0]}")];
if (parts.Length > 1)
{
claims.AddRange(parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(role => new Claim(ClaimTypes.Role, role)));
}
ClaimsIdentity identity = new(claims, SchemeName);
AuthenticationTicket ticket = new(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
namespace Bookstore.Api.IntegrationTests.Infrastructure;
public sealed record TestUser(int CustomerId, params string[] Roles)
{
public static TestUser Customer(int id) => new(id);
public static TestUser Admin { get; } = new(1, "admin");
public string HeaderValue => $"{CustomerId};{string.Join(',', Roles)}";
}
Learn'deki örnek handler her isteği aynı kullanıcıyla doğruluyor. Bu, "giriş yapmış kullanıcı" senaryosu için yeterli ama anonim isteği test etmeyi imkânsız kılıyor. Header yoksa NoResult dönmek, üç durumu da aynı factory ile test etmeyi sağlıyor: header yok, header var ama rol eksik, header var ve rol doğru. services.AddAuthentication(TestAuthHandler.SchemeName) çağrısı default scheme'i değiştirdiği için challenge ve forbid de bu handler'a düşüyor; JWT handler'ı kayıtlı kalıyor ama hiç devreye girmiyor.
[Fact]
public async Task Anonymous_user_gets_401()
{
HttpClient client = factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync("/api/books", NewBook("978-0321125217"),
TestContext.Current.CancellationToken);
ProblemDetails problem = await response.AssertProblemAsync(HttpStatusCode.Unauthorized);
Assert.Equal("Unauthorized", problem.Title);
}
[Fact]
public async Task Customer_without_admin_role_gets_403()
{
HttpClient client = factory.CreateClientFor(TestUser.Customer(42));
HttpResponseMessage response = await client.PostAsJsonAsync("/api/books", NewBook("978-0321125217"),
TestContext.Current.CancellationToken);
ProblemDetails problem = await response.AssertProblemAsync(HttpStatusCode.Forbidden);
Assert.Equal("Forbidden", problem.Title);
}
[Fact]
public async Task Admin_creates_book_and_gets_its_location()
{
HttpClient client = factory.CreateClientFor(TestUser.Admin);
HttpResponseMessage response = await client.PostAsJsonAsync("/api/books", NewBook("978-0321125218"),
TestContext.Current.CancellationToken);
int id = response.AssertCreatedAt("/api/books/");
BookDto? created = await client.GetFromJsonAsync<BookDto>($"/api/books/{id}", TestContext.Current.CancellationToken);
Assert.Equal("978-0321125218", created!.Isbn);
}
401 "kim olduğunu bilmiyorum", 403 "kim olduğunu biliyorum ama bu işe yetkin yok" demek. İkisini ayırmak önemli, çünkü bir authorization hatası çoğu zaman tam bu sınırda çıkar: policy yanlış endpoint'e bağlanır, ya da rol kontrolü yerine yalnız RequireAuthorization() konur ve her giriş yapmış müşteri katalog yöneticisi olur. Rol ve claim kombinasyonlarının tamamını matris hâlinde test etmek API Güvenliği serisinin 1. ve 10. yazılarının konusu; burada her policy için bu üç testi yazmak yeterli bir taban.
Redirect, ProblemDetails ve Location İçin Küçük Helper'lar
Bookstore'un eski katalog linkleri ISBN (uluslararası standart kitap numarası) ile geliyor ve kalıcı adrese 301 ile yönlendiriliyor. Varsayılan test client'ı redirect'leri takip ettiği için bu davranışı görmek mümkün değil; client'ı AllowAutoRedirect = false ile almadan yaptığım denemede istek 200 ve http://localhost/api/books/1 adresiyle bitti. Yönlendirmenin kendisini doğrulamak için:
[Fact]
public async Task Old_isbn_link_redirects_permanently_to_canonical_url()
{
HttpClient admin = factory.CreateClientFor(TestUser.Admin);
HttpResponseMessage created = await admin.PostAsJsonAsync("/api/books", NewBook("978-0321125220"),
TestContext.Current.CancellationToken);
int id = created.AssertCreatedAt("/api/books/");
HttpClient client = factory.CreateClientFor(TestUser.Customer(42), allowAutoRedirect: false);
HttpResponseMessage response = await client.GetAsync("/books/isbn/978-0321125220", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode);
Assert.Equal($"/api/books/{id}", response.Headers.Location?.OriginalString);
}
Yukarıdaki testlerin kullandığı helper'lar, her testte tekrar eden üç kontrolü topluyor: status code, application/problem+json content type'ı ve gövdenin doğru tipte okunması.
public static class HttpAssertions
{
public static async Task<HttpValidationProblemDetails> AssertValidationProblemAsync(this HttpResponseMessage response)
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
HttpValidationProblemDetails? problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
return Assert.IsType<HttpValidationProblemDetails>(problem);
}
public static async Task<ProblemDetails> AssertProblemAsync(this HttpResponseMessage response, HttpStatusCode expected)
{
Assert.Equal(expected, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
return Assert.IsType<ProblemDetails>(problem);
}
public static int AssertCreatedAt(this HttpResponseMessage response, string pathPrefix)
{
// 201 + Location: /api/books/{id}; id'yi testin devamı için döndür
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
string location = Assert.IsType<Uri>(response.Headers.Location).OriginalString;
Assert.StartsWith(pathPrefix, location);
return int.Parse(location[pathPrefix.Length..]);
}
}
401 ve 403'ün de ProblemDetails gövdesiyle dönmesi, AddProblemDetails ve UseStatusCodePages sayesinde. Gövdesiz bir status code'u bu helper reddeder; bu bilerek böyle, çünkü API'nin hata sözleşmesi Angular tarafının da güvendiği bir şey. Gerçek gövdeler:
401 application/problem+json {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"00-606b8a27aac446e1aaaebc295dcc9a8b-8eb04a050b6d1171-00"}
403 application/problem+json {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,"traceId":"00-73fbd0e74ebe264f31fcca5caeb8ce0a-4183051e61f0d921-00"}
400 application/problem+json {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"price":["Price must be greater than zero."],"title":["Title is required."]},"traceId":"00-1c22ebd95a255b216ed871d63a8e5fdb-ea4d189f30d68eb5-00"}
Validation testi bu gövdedeki errors anahtarlarına bakıyor:
HttpValidationProblemDetails problem = await response.AssertValidationProblemAsync();
Assert.Equal(["price", "title"], problem.Errors.Keys.Order());
Bütün suite'in ayrıntılı çıktısı (yolları kısalttım):
$ dotnet test --project tests/Bookstore.Api.IntegrationTests --output detailed
passed Bookstore.Api.IntegrationTests.CatalogAuthorizationTests.Old_isbn_link_redirects_permanently_to_canonical_url (310ms)
passed Bookstore.Api.IntegrationTests.CatalogAuthorizationTests.Invalid_book_returns_validation_problem (13ms)
passed Bookstore.Api.IntegrationTests.CatalogAuthorizationTests.Customer_without_admin_role_gets_403 (2ms)
passed Bookstore.Api.IntegrationTests.CatalogAuthorizationTests.Admin_creates_book_and_gets_its_location (29ms)
passed Bookstore.Api.IntegrationTests.CatalogAuthorizationTests.Anonymous_user_gets_401 (1ms)
passed Bookstore.Api.IntegrationTests.BookCatalogTests.Unknown_book_returns_404_problem_details (370ms)
passed Bookstore.Api.IntegrationTests.BookEditionTests.Second_edition_is_listed_with_its_own_price (379ms)
passed Bookstore.Api.IntegrationTests.BookCatalogTests.Search_is_case_insensitive_like_production (10ms)
total: 8
failed: 0
succeeded: 8
duration: 12s 662ms
Her class'ın ilk testi 300 milisaniyenin üstünde, sonrakiler birkaç milisaniye. Aradaki fark host'un ayağa kalkması; sıradaki bölümün konusu tam olarak bu.
Factory'yi Paylaşmak ve WithWebHostBuilder'ın Bedeli
Factory'yi her test için yeniden oluşturmanın, aynı factory'den yeni client almanın ve WithWebHostBuilder ile bir varyasyon üretmenin maliyetini ölçtüm. Her turda aynı istek atıldı:
using WebApplicationFactory<Program> variant = factory.WithWebHostBuilder(b =>
b.ConfigureTestServices(s => s.AddSingleton<TimeProvider>(new FakeTimeProvider())));
await variant.CreateClient().GetAsync("/api/books/1", TestContext.Current.CancellationToken);
round 1: new factory 262 ms, same factory 17 ms, WithWebHostBuilder 55 ms
round 2: new factory 42 ms, same factory 1 ms, WithWebHostBuilder 46 ms
round 3: new factory 51 ms, same factory 1 ms, WithWebHostBuilder 43 ms
İlk tur JIT (kodun ilk çalıştığı anda derlenmesi) ısınmasını içeriyor. Sonraki turlarda tablo net: aynı factory'den client almak 1 milisaniye, WithWebHostBuilder ise yeni bir factory kadar pahalı, çünkü arka planda yeni bir host build edip başlatıyor. Bookstore'un küçük Program.cs'iyle bu 45 milisaniye civarı; DI (dependency injection; bağımlılıkların container'dan verilmesi) registration'ları ve startup işi büyüdükçe artıyor. 300 testin her biri WithWebHostBuilder kullanırsa bu, testlerin kendisinden bağımsız 10–15 saniyelik ek süre demek. Kural: varyasyon gerçekten farklı bir registration gerektiriyorsa WithWebHostBuilder, yalnız farklı bir kullanıcı ya da farklı veri gerekiyorsa aynı factory'den yeni client.
Paylaşımın kapsamı için xUnit v3'te iki seçenek var. IClassFixture<BookstoreApiFactory> her test class'ına bir host verir; class'lar paralel koşabilir ve her birinin host'u bağımsızdır. Factory'yi de container gibi assembly fixture yapıp tek host'la bütün suite'i koşturmak cazip görünüyor; denediğimde derleme şu hatayla durdu:
error xUnit3005: Type 'Bookstore.Api.IntegrationTests.BookstoreApiFactory' must have a non-obsolete public constructor: public Bookstore.Api.IntegrationTests.BookstoreApiFactory([ITestContextAccessor accessor], [ITestOutputHelper helper], [IMessageSink diagnosticMessageSink])
Assembly fixture'ın constructor'ı başka bir fixture alamıyor; yalnız xUnit'in kendi service'lerini alabiliyor. Tek host isteyen biri container'ı ve factory'yi aynı assembly fixture class'ının içinde yönetmek zorunda. Bookstore'da class fixture'da kalıyorum: test class sayısı kadar host (burada 45 milisaniye civarı) ödeniyor, buna karşılık bir class'ın host'unda yaptığı değişiklik diğerine sızmıyor.
Üretimde Dikkat
Test authentication handler'ı yalnız test projesinde yaşamalı. API projesine "Testing ortamında header'dan kullanıcı oku" gibi bir kod girdiği an, yanlış ortam değişkeniyle production'a çıkan bir build'in herkese admin yetkisi verme ihtimali doğar. Handler'ı ConfigureTestServices içinde kaydetmek, bu kodun production assembly'sine hiç girmemesini garanti ediyor.
JWT doğrulamasının kendisi bu testlerin dışında kaldı: issuer, audience, imza ve süre kontrolleri. Bunlar için birkaç ayrı test, gerçek bir imzalama anahtarıyla üretilmiş token'larla yazılmalı; kaç tanesinin ve hangi seviyede yazılacağı API Güvenliği serisinde anlatıldı.
Configuration'ı erken okuyan kodu code review'da yakala. builder.Configuration[...] çağrısının sonucu bir local değişkene atanıp bir lambda'ya taşınıyorsa, testte hangi değerin görüneceği factory'nin hangi yöntemi kullandığına bağlı hâle geliyor. Options pattern ve service provider üzerinden geç okuma bu belirsizliği ortadan kaldırıyor.
Sık Düşülen Tuzaklar
Her test için yeni factory. using BookstoreApiFactory factory = new(sql); satırını test method'unun başına yazmak testleri tam izole hissettiriyor, ama ölçümdeki 40–50 milisaniyelik host maliyetini her teste ekliyor ve veritabanını yine paylaşıyor. Asıl izolasyon sorunu veride; o da 3. yazının konusu.
Background service'lerin testte istemeden çalışması. Factory'nin ilk sürümünde LowStockReportService kayıtlıydı ve migration'ı host başladıktan sonra uyguluyordum. Service, migration'dan önce veritabanına bağlanmaya çalıştı:
fail: Microsoft.Extensions.Hosting.Internal.Host[9]
BackgroundService failed
System.InvalidOperationException: An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
---> Microsoft.Data.SqlClient.SqlException (0x80131904): Cannot open database "BookstoreDb" requested by the login. The login failed.
Login failed for user 'sa'.
crit: Microsoft.Extensions.Hosting.Internal.Host[10]
The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. A BackgroundService has thrown an unhandled exception, and the IHost instance is stopping. To avoid this behavior, configure this to Ignore; however the BackgroundService will not be restarted.
Host durdu ve iki test de ilgisiz bir hatayla kaldı: System.ObjectDisposedException : Cannot access a disposed object. Object name: 'Microsoft.AspNetCore.TestHost.TestServer'. Hatayı okuyan biri testlerde bir sorun arar; asıl neden testlerin hiç ilgilenmediği bir background service. Zamanlayıcıyla çalışan, mesaj tüketen ya da dışarıya istek atan her hosted service için karar ver: testte gerekmiyorsa registration'ını sil, gerekiyorsa onu test eden ayrı bir test class'ı yaz ve zamanı FakeTimeProvider ile kontrol et.
Default scheme'i değiştirmeden test handler'ı eklemek. Yalnız AddScheme çağırıp AddAuthentication(TestAuthHandler.SchemeName) çağırmazsan default scheme JWT bearer olarak kalır; test handler'ı kayıtlıdır ama hiçbir istek ona uğramaz. Denedim: admin isteği WWW-Authenticate: Bearer header'ıyla 401 aldı. Policy'lere scheme adı yazılmışsa (AuthenticationSchemes = "Bearer") test handler'ını aynı adla kaydetmen gerekir.
ConfigureAppConfiguration'a güvenip erken okunan değeri değiştirdiğini sanmak. Önce Tahmin Et'teki tablo bunun sessiz bir hata olduğunu gösteriyor: test yeşil kalabiliyor, çünkü gerçek adrese gitmeye çalışan istek de bir şekilde cevaplanmış ya da hiç atılmamış olabiliyor.
Küçük Bir Deneme
★☆☆ TestAuthHandler'ı header yokken AuthenticateResult.Fail("no user") dönecek şekilde değiştir. Anonymous_user_gets_401 hâlâ geçiyor mu? Anonim erişime açık GET /api/books için ne değişiyor?
★★☆ CatalogAdmin policy'sine "kullanıcının catalog:write claim'i olmalı" şartını ekle. TestUser'ı claim taşıyacak şekilde genişlet ve 403'ü yalnız claim eksikken üreten bir test yaz.
★★★ Container'ı ve factory'yi tek bir assembly fixture'da yöneten bir yapı kur. Suite süresini class fixture hâliyle karşılaştır, sonra iki class'ın paralel çalışırken aynı host üzerinde birbirini etkileyebileceği bir senaryo bul (ipucu: FakeTimeProvider'ı ilerleten bir test).
Factory artık kullanıcıları, configuration'ı ve service'leri kontrol ediyor. Ama bütün test class'ları hâlâ aynı veritabanını paylaşıyor ve 1. yazıdaki sıra bağımlılığı çözülmedi. Bir sonraki yazıda Testcontainers'ın yaşam döngüsüne, migration'lara ve testler arası temizliğin üç yoluna bakıyoruz: Respawn, transaction rollback ve test başına veritabanı.