Mert Özen Her Satırda Daha İleri
Test ve Kalite Unit Test — #09

Test Edilebilir Tasarım: Dependency Injection, Seam'ler, Functional Core ve Humble Object

Mert Özen 8 Kas 2026 9 dk 4 görüntülenme
Test Edilebilir Tasarım: Dependency Injection, Seam'ler, Functional Core ve Humble Object

Kodu test edilebilir yapan şey kararları saf bir çekirdeğe çekip zamanı, I/O'yu ve çerçeve kodunu ince bir kabukta bırakmaktır; o zaman kararlar mock'suz test edilir. DateTime.Now kullanan bir indirim testi takvime bağlı kaldı ve İstanbul ile UTC farkını gizledi. Seam türleri, TimeProvider, functional core / imperative shell, Humble Object ile BackgroundService ve legacy kod için characterization test.

Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2 ve Microsoft.Extensions.TimeProvider.Testing 10.11.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore pazar günleri "Pazar okuması" kampanyası yapıyor: o gün verilen siparişlerde %5 indirim. Kod ve testleri:

// Önce: zaman doğrudan okunuyor, seam yok
public sealed class LegacySundayPricing
{
    public decimal PriceFor(decimal subtotal) =>
        DateTime.Now.DayOfWeek == DayOfWeek.Sunday
            ? Math.Round(subtotal * 0.95m, 2, MidpointRounding.AwayFromZero)
            : subtotal;
}
public sealed class LegacySundayPricingTests
{
    [Fact]
    public void Sunday_orders_get_five_percent_off() =>
        Assert.Equal(95.00m, new LegacySundayPricing().PriceFor(100m));

    [Fact]
    public void Weekday_orders_pay_full_price() =>
        Assert.Equal(100m, new LegacySundayPricing().PriceFor(100m));
}

Bu yazıyı yazdığım gün, 8 Kasım 2026'da testleri çalıştırdım. Hangisi geçti? Yarın hangisi geçer? Sunucular UTC'de (Coordinated Universal Time) çalışırken İstanbul'da pazar sabahı 01:30'da verilen bir sipariş indirim alır mı?

Cevap: Takvime Bağlı Bir Test Suite'i

failed Bookstore.Pricing.Tests.LegacySundayPricingTests.Weekday_orders_pay_full_price (1ms)
  Assert.Equal() Failure: Values differ
  Expected: 100
  Actual:   95.00

Test run summary: Failed!
  total: 10
  failed: 1
  succeeded: 9

8 Kasım 2026 bir pazar; pazar testi geçti, hafta içi testi kaldı. Yarın tersi olacak. İki testten biri her gün kırmızı ve hangisinin kırmızı olduğu takvime bağlı. Üçüncü sorunun cevabı da kötü: DateTime.Now sunucunun yerel saatini okur. Sunucu UTC'deyse, İstanbul'da pazar 00:00 ile 03:00 arası verilen siparişler sunucuya göre hâlâ cumartesi ve indirim almaz. Bu kodu test etmenin yolu yok, çünkü test edilen davranışın girdisi (bugün hangi gün?) koda dışarıdan verilmiyor. Bu yazıda kodu test edilebilir yapan tasarım hamlelerini sırayla uyguluyoruz: seam, functional core / imperative shell, Humble Object ve legacy kod için characterization test.

Constructor Injection ve Seam'ler

Michael Feathers, Working Effectively with Legacy Code (2004) kitabında seam'i şöyle tanımlar: kodu o noktada düzenlemeden davranışını değiştirebildiğin yer. Feathers üç tür sayar: preprocessing seam (C/C++ makroları), link seam (derleme ya da bağlama sırasında başka bir implementation'ı bağlamak) ve object seam (bir interface ya da virtual method üzerinden başka bir object vermek). C#'ta pratikte object seam kullanırız ve onu açmanın en temiz yolu constructor injection: bağımlılık constructor parametresi olarak gelir, class onu yaratmaz.

Seam gerektiren tipik bağımlılıklar şunlar: DateTime.Now/UtcNow yerine TimeProvider; Guid.NewGuid() ve Random yerine üretici bir fonksiyon ya da interface; static ve singleton erişimler (ConfigurationManager.AppSettings, HttpContext.Current) yerine IOptions<T> ve açık parametreler; File, Environment, ağ. Ortak özellikleri, çalıştıkları ortamdan değer okumaları: test bu değeri kontrol edemiyorsa testin sonucu ortama bağlı olur. ASP.NET Core Web API (Application Programming Interface) serisi dependency injection mekaniğini, C# İleri serisinin 14. yazısı da zamanı dışarıdan almayı anlatıyor; burada soruyu tasarım açısından ele alıyoruz: seam nereye konmalı?

Functional Core, Imperative Shell

İlk refleks LegacySundayPricing'e bir TimeProvider vermek olurdu. Daha iyi bir cevap Gary Bernhardt'ın 2012'deki "Functional Core, Imperative Shell" konuşmasından geliyor: kararları veren kodu saf bir çekirdeğe çek, I/O ve zaman gibi dünyaya dokunan her şeyi ince bir kabukta bırak.

09-functional-core-tr.png

public sealed record PriceBreakdown(decimal Subtotal, decimal SundayDiscount, decimal LoyaltyDiscount)
{
    public decimal Total => Subtotal - SundayDiscount - LoyaltyDiscount;
}

// Functional core: I/O yok, zaman yok, yalnız girdi ve çıktı
public static class PricingRules
{
    public static PriceBreakdown Price(decimal subtotal, DayOfWeek localDay, int completedOrders)
    {
        decimal sunday = localDay == DayOfWeek.Sunday ? Round(subtotal * 0.05m) : 0m;
        decimal loyalty = completedOrders >= 5 ? Round((subtotal - sunday) * 0.05m) : 0m;
        return new PriceBreakdown(subtotal, sunday, loyalty);
    }

    private static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
}

public interface ICustomerHistory
{
    Task<int> CompletedOrderCountAsync(string customerId, CancellationToken ct);
}

// Imperative shell: veriyi toplar, core'u çağırır, karar vermez
public sealed class CheckoutPricing(ICustomerHistory history, TimeProvider time)
{
    private static readonly TimeZoneInfo Istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");

    public async Task<PriceBreakdown> PriceAsync(string customerId, decimal subtotal, CancellationToken ct)
    {
        int completedOrders = await history.CompletedOrderCountAsync(customerId, ct);
        DayOfWeek localDay = TimeZoneInfo.ConvertTime(time.GetUtcNow(), Istanbul).DayOfWeek;
        return PricingRules.Price(subtotal, localDay, completedOrders);
    }
}

Pazar indirimi ve sadakat indirimi artık PricingRules.Price'ta ve bu fonksiyonun bütün girdileri parametre. Testi, 08. yazının output-based stiliyle, mock'suz:

[Theory]
[InlineData(DayOfWeek.Sunday, 0, 5.00, 0)]
[InlineData(DayOfWeek.Saturday, 0, 0, 0)]
[InlineData(DayOfWeek.Monday, 5, 0, 5.00)]
[InlineData(DayOfWeek.Sunday, 5, 5.00, 4.75)]
public void Discounts_for_a_100_euro_order(
    DayOfWeek day, int completedOrders, decimal sunday, decimal loyalty)
{
    PriceBreakdown price = PricingRules.Price(100m, day, completedOrders);

    Assert.Equal(new PriceBreakdown(100m, sunday, loyalty), price);
}

Son satır iki kuralın birleşimini sabitliyor: sadakat indirimi, pazar indirimi düşüldükten sonraki tutar üzerinden (95 × %5 = 4,75). Bu tür kurallar iş tarafıyla konuşulurken netleşir ve tam olarak bu tür bir test tablosuyla belgelenir. Kabuk ise yalnız iki şeyi doğru yapmak zorunda: doğru veriyi toplamak ve doğru günü hesaplamak. Onun için tek bir test yetiyor ve o test saat dilimi sınırına bakıyor:

[Fact]
public async Task Saturday_night_utc_is_already_sunday_in_istanbul()
{
    // 7 Kasım 2026 22:30 UTC = 8 Kasım 01:30 İstanbul (Pazar)
    FakeTimeProvider time = new(new DateTimeOffset(2026, 11, 7, 22, 30, 0, TimeSpan.Zero));
    CheckoutPricing sut = new(new StubHistory(completed: 0), time);

    PriceBreakdown price = await sut.PriceAsync("C-1", 100m, TestContext.Current.CancellationToken);

    Assert.Equal(5.00m, price.SundayDiscount);
}

Bu test bugün de, yarın da, her saat diliminde çalışan bir CI (Continuous Integration) makinesinde de aynı sonucu veriyor. Kazanım yalnız test edilebilirlik değil: Önce Tahmin Et'teki saat dilimi hatası, kabuğun tek sorumluluğu "yerel günü hesaplamak" olarak adlandırıldığı an görünür oldu.

Humble Object: Test Edilmesi Zor Olanı İnceltmek

Bazı kodlar doğası gereği test edilmesi zor: BackgroundService döngüleri, controller'lar, UI (User Interface) event handler'ları, framework'ün çağırdığı callback'ler. Meszaros'un xUnit Test Patterns kitabındaki Humble Object pattern'i şunu söyler: test edilmesi zor olan kısmı o kadar incelt ki içinde test etmeye değer bir mantık kalmasın; mantığı test edilebilir ayrı bir yere taşı.

// Test edilen karar: saf ve deterministik
public static class LoyaltyReminders
{
    public static IReadOnlyList<string> Select(IEnumerable<CustomerActivity> customers, DateOnly today) =>
        customers
            .Where(c => c.CompletedOrders >= 5 && today.DayNumber - c.LastOrder.DayNumber >= 60)
            .Select(c => c.Email)
            .ToList();
}

// Humble Object: yalnız döngü ve I/O, mantık yok
public sealed class LoyaltyReminderWorker(
    ICustomerActivityStore store, IReminderSender sender, TimeProvider time) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(TimeSpan.FromHours(24), time);
        do
        {
            IReadOnlyList<CustomerActivity> customers = await store.LoadAsync(stoppingToken);
            DateOnly today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime);
            foreach (string email in LoyaltyReminders.Select(customers, today))
            {
                await sender.SendAsync(email, stoppingToken);
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}
[Fact]
public void Loyal_customers_silent_for_60_days_get_a_reminder()
{
    CustomerActivity[] customers =
    [
        new("ada@example.com", Today.AddDays(-60), CompletedOrders: 7),
        new("alan@example.com", Today.AddDays(-59), CompletedOrders: 7),
        new("grace@example.com", Today.AddDays(-90), CompletedOrders: 4),
    ];

    IReadOnlyList<string> selected = LoyaltyReminders.Select(customers, Today);

    Assert.Equal(["ada@example.com"], selected);
}

"Kime hatırlatma gidecek?" kararı, üç satırlık veriyle ve sınır değerleriyle (60. gün dahil, 59. gün hariç, 4 sipariş yetmez) test ediliyor. Worker'ın kendisinde test etmeye değer bir şey kalmadı: yükle, seç, gönder, bekle. Onu çalışan bir host içinde bir kez görmek integration test'in işi; döngüyü FakeTimeProvider ile ilerleterek test etmek 11. yazının konusu. Aynı fikir controller'lara da uygulanır: controller HTTP (HyperText Transfer Protocol) isteğini domain'e çeviren ince bir katman olduğunda, unit test'lerin yükü arkadaki servise geçer.

Legacy Kod: Characterization Test ve Extract and Override

Her kodu baştan doğru tasarlama şansın olmuyor. Testi olmayan, değiştirilmesi gereken eski bir class'la karşılaştığında Feathers'ın önerisi iki adım: önce mevcut davranışı sabitleyen testler yaz, sonra küçük ve güvenli adımlarla seam aç. Mevcut davranışı sabitleyen teste characterization test denir; "doğru olan" değil, "şu an olan" davranışı kaydeder.

// Legacy kod: fatura numarası üretici
public class LegacyInvoiceNumbers
{
    public string Next(int sequence) => $"INV-{Now():yyyyMM}-{sequence:D5}";

    // Extract and override: zamanı virtual bir method'a çektik
    protected virtual DateTime Now() => DateTime.Now;
}
public sealed class LegacyInvoiceNumbersTests
{
    private sealed class FixedClockInvoiceNumbers(DateTime now) : LegacyInvoiceNumbers
    {
        protected override DateTime Now() => now;
    }

    private readonly LegacyInvoiceNumbers sut = new FixedClockInvoiceNumbers(new DateTime(2026, 11, 8));

    // Characterization test: bugünkü davranışı olduğu gibi sabitler
    [Fact]
    public void Formats_year_month_and_five_digit_sequence() =>
        Assert.Equal("INV-202611-00042", sut.Next(42));

    [Fact]
    public void Sequence_above_99999_grows_to_six_digits() =>
        Assert.Equal("INV-202611-100000", sut.Next(100_000));
}

"Extract and Override Call", Feathers'ın kataloğundaki en küçük seam hamlelerinden biri: DateTime.Now çağrısını protected virtual bir method'a çek, testte bir alt class ile override et. Public API değişmedi, constructor değişmedi, production davranışı aynı. İkinci test ilginç: 99999'u aşan sıra numarası altı haneye çıkıyor. Bu doğru mu, hata mı? Characterization test bunu söylemez; yalnız "şu an böyle" der. Muhasebe bunun bir hata olduğunu söylerse testi değiştirir, kodu düzeltirsin; ama artık bunu bilerek yaparsın. Büyük ölçekli toparlama, yani legacy bir kod tabanını adım adım yeniden yapılandırmak, ilerideki Refactoring serisinin konusu.

Üretimde Dikkat

TimeProvider'ı DI (dependency injection) container'ına kaydet: services.AddSingleton(TimeProvider.System). Böylece class'lar TimeProvider'ı constructor'dan alır ve testte FakeTimeProvider verilir. Uygulamanın her yerinde TimeProvider.System'e doğrudan erişmek, DateTime.Now'u başka bir isimle kullanmaktır.

Saat dilimi kararını tek bir yerde ver. Bookstore'un "pazar"ı İstanbul'un pazarı; bu bilgi CheckoutPricing'te bir kez duruyor. Saat dilimini her class kendi hesaplarsa, gece yarısı civarındaki siparişler farklı class'larda farklı günlere düşer. Hangi saat diliminin geçerli olduğu bir iş kararıdır ve açıkça yazılmalıdır.

Functional core'u büyütmeye çalış. Yeni bir iş kuralı geldiğinde ilk soru "bu kural hangi verilere ihtiyaç duyuyor ve bu veriler parametre olarak verilebilir mi?" olsun. Cevap evet ise kural çekirdeğe gider ve mock'suz test edilir; kabuk yalnız yeni veriyi toplamayı öğrenir.

Sık Düşülen Tuzaklar

Sırf test için her class'a interface eklemek. IPricingRules, ILoyaltyReminders, IPriceBreakdownFactory... Saf bir fonksiyonun interface'e ihtiyacı yok, çünkü onu taklit etmek için bir neden yok: gerçeği hızlı ve deterministik. Interface sistem sınırındaki bağımlılıklar için (ICustomerHistory, IReminderSender); geri kalan her interface, değişikliği iki dosyaya yayan bir tören. Tek implementation'lı interface'lerin çoğu, 01. yazıdaki "her collaborator'a bir mock" refleksinin altyapısıdır.

Private method'u test etmek için her şeyi açmak. InternalsVisibleTo ile internal'ları test projesine açmak bazen makul (bu serinin 02. yazısında bir setter için kullandık), ama private bir method'u test etmek için onu internal yapmak testleri implementation'a bağlar. Private method'u public davranış üzerinden test et. Bir private method kendi başına test edilmek isteyecek kadar karmaşıksa, o muhtemelen ayrı bir class'ın ya da saf bir fonksiyonun public method'u olmak istiyor; PricingRules gibi.

Seam'i yanlış seviyede açmak. DateTime.Now'u bir IClock interface'ine sarmak ve sonra her testte clock.Now.Returns(...) kurmak çalışır, ama kararı hâlâ zamanın okunduğu yerde bırakır. Saati kabukta oku, çekirdeğe değer olarak ver.

Characterization test'i spesifikasyon sanmak. Bu testler "şu anki davranış" kaydıdır; içlerinde hatalar da sabitlenmiştir. Adlarına ve yorumlarına bunu yaz ki bir sonraki geliştirici onları iş kuralı sanmasın.

Küçük Bir Deneme

★☆☆ LegacySundayPricing'i silmeden, constructor'dan TimeProvider alacak şekilde değiştir ve iki testini FakeTimeProvider ile yeniden yaz. Testler artık hangi gün çalıştırılırsa çalıştırılsın aynı sonucu veriyor mu?

★★☆ PricingRules.Price'a yeni bir kural ekle: 200 EUR ve üzeri siparişte ek %3. Kuralın pazar ve sadakat indirimleriyle nasıl birleşeceğine karar ver ve kararı theory satırlarıyla belgele. Kabukta bir şey değişti mi?

★★★ Kendi projende DateTime.Now, DateTime.UtcNow ya da Guid.NewGuid() çağıran bir class bul. Önce characterization testlerini extract and override ile yaz, sonra kararı veren kısmı saf bir fonksiyona çek ve testleri o fonksiyona taşı. Kaç test mock'suz hâle geldi?

Bir sonraki yazıda test kodunun kendisine dönüyoruz: test verisini kurmak için Test Data Builder, Object Mother, AutoFixture ve Bogus; test kodunda DRY (Don't Repeat Yourself) ile DAMP (Descriptive And Meaningful Phrases) arasındaki denge.