Mert Özen Her Satırda Daha İleri
Geliştirici Araçları Debugging ve Problem Çözme — #02

Bug'ı Reproduce Etmek: Minimal Repro, Ortam Farkları ve Önce Kırmızı Test

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Bug'ı Reproduce Etmek: Minimal Repro, Ortam Farkları ve Önce Kırmızı Test

Reproduce edemediğin bug'ı düzeltemezsin: önce kullanıcıdan doğru bilgiyi al, culture, timezone, configuration ve sürüm farklarını kontrol et, hatayı minimal repro'ya indir ve düzeltmeden önce kırmızı bir xUnit testi yaz. Container'da 1245'e dönüşen 12,45 ve iki satıra inen sipariş toplamı hatası gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 (xunit.v3 4.0.2) ve Microsoft.Testing.Platform ile çalıştırılmıştır.

Önce Tahmin Et

Aşağıdaki dört satırlık console uygulamasını iki yerde çalıştırıyorsun: önce kendi Mac'inde (Türkçe macOS, İstanbul saati), sonra resmi mcr.microsoft.com/dotnet/aspnet:10.0 image'ından ayağa kalkan bir Docker container'ında. İki ortamda da aynı derlenmiş dll çalışıyor.

Console.WriteLine($"culture      : {CultureInfo.CurrentCulture.Name}");
Console.WriteLine($"DateTime.Now : {DateTime.Now:yyyy-MM-dd HH:mm}");
Console.WriteLine($"parse 12,45  : {decimal.Parse("12,45")}");
Console.WriteLine($"\"title\".ToUpper() : {"title".ToUpper()}");

Hangi satırlar iki ortamda farklı çıkar? decimal.Parse("12,45") container'da ne döner: 12,45 mi, 12.45 mi, bir exception mı, yoksa başka bir şey mi? Cevabı ve gerçek çıktıyı ortam farkları bölümünde göreceğiz.

Reproduce Edilemeyen Bug Düzeltilemez

Serinin ilk yazısında muhasebeden gelen ticket ile başladık: #10482 numaralı siparişte tahsil edilen tutar faturadan 1 kuruş az. İki hesabı yan yana koyunca farkın %10 indirimli 12,45 TL'lik satırdan geldiğini ve ortadaki değerlerin farklı yuvarlandığını gördük. Agans'ın ikinci kuralı, "Make It Fail", şimdi devreye giriyor: düzeltmeye geçmeden önce bu hatayı istediğimiz an, istediğimiz ortamda, aynı şekilde üretebilmeliyiz.

Sebebi basit. Hatayı tetikleyemiyorsan, düzelttiğini de kanıtlayamazsın. "Değişikliği yaptım, birkaç gün bekledim, şikâyet gelmedi" bir kanıt değildir; belirti zaten "bazen" görünüyordu. Reproduce etmek aynı zamanda hipotezi daraltır: hatayı bir veriyle üretip başka bir veriyle üretemiyorsan, aradaki fark sana sebebe giden yolu gösterir.

Kullanıcıdan Doğru Bilgiyi Almak

Bug'ların çoğu bir kullanıcının, bir destek ekibinin ya da başka bir ekibin raporuyla gelir. "Sipariş toplamı yanlış" cümlesiyle hiçbir şey reproduce edilemez. İyi bir bug raporunda şunlar olmalı:

  • Adım adım ne yapıldığı, ne beklendiği ve ne olduğu (beklenen ve gerçekleşen davranış ayrı ayrı)
  • Zaman, timezone'uyla birlikte: "dün akşam" değil, UTC (Coordinated Universal Time) farkıyla "2026-09-21 09:42 UTC+3"
  • Hangi kullanıcı, hangi sipariş, hangi request: sipariş numarası ya da ProblemDetails yanıtındaki traceId
  • Uygulamanın sürümü, tarayıcı ve sürümü, cihaz ve işletim sistemi
  • Ekran görüntüsü ve gerekiyorsa sanitized bir HAR (HTTP Archive) dosyası

Bu listeyi bir issue template'ine koymak, raporlayanı her seferinde aynı soruları sormaktan kurtarır. Bizim ticket'ımız şanslı: muhasebe sipariş numarasını ve iki tutarı vermiş. Sipariş numarasıyla veritabanından satırları, indirim oranlarını ve siparişin hangi uygulama sürümüyle oluştuğunu çekebiliyoruz.

HAR dosyası konusunda dikkatli ol. HAR, tarayıcının Network panelindeki bütün request ve response'ları cookie'leri ve header'larıyla birlikte JSON (JavaScript Object Notation) olarak kaydeder; içinde oturum cookie'si ya da Authorization header'ı varsa, dosyayı alan kişi o kullanıcının oturumunu kullanabilir. Chrome 130'dan beri DevTools'un "Export HAR" seçeneği varsayılan olarak sanitized dosya üretiyor; Cookie, Set-Cookie ve Authorization header'larını dışarıda bırakıyor. Hassas veriyle export için ayrıca bir ayar açmak gerekiyor. Yine de URL'lerdeki (Uniform Resource Locator) query string'ler, request body'leri ve response'lardaki kişisel veriler dosyada kalır; HAR'ı ticket'a eklemeden önce aç ve göz at. Network panelinin ayrıntıları ilerideki Browser DevTools serisinde.

Ortam Farkları Kontrol Listesi

Hata bir ortamda olup diğerinde olmuyorsa, iki ortam arasındaki farklardan biri ya sebeptir ya da tetikleyicidir. Baştaki programın tam halini iki ortamda çalıştıralım. Önce kendi makinemde, Türkçe culture ve İstanbul saatiyle:

using System.Globalization;

Console.WriteLine($"culture      : {CultureInfo.CurrentCulture.Name}");
Console.WriteLine($"time zone    : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"DateTime.Now : {DateTime.Now:yyyy-MM-dd HH:mm}");
Console.WriteLine($"UtcNow       : {DateTime.UtcNow:yyyy-MM-dd HH:mm}");
Console.WriteLine($"parse 12,45  : {decimal.Parse("12,45")}");
Console.WriteLine($"\"title\".ToUpper() : {"title".ToUpper()}");
$ LANG=tr_TR.UTF-8 TZ=Europe/Istanbul dotnet bin/Debug/net10.0/envcheck.dll
culture      : tr-TR
time zone    : Europe/Istanbul
DateTime.Now : 2026-11-08 11:56
UtcNow       : 2026-11-08 08:56
parse 12,45  : 12,45
"title".ToUpper() : TİTLE

Sonra aynı dll'i, production'daki gibi resmi ASP.NET Core runtime image'ında:

$ docker run --rm -v "$PWD/bin/Debug/net10.0:/app:ro" mcr.microsoft.com/dotnet/aspnet:10.0 dotnet /app/envcheck.dll
culture      :
time zone    : UTC
DateTime.Now : 2026-11-08 08:57
UtcNow       : 2026-11-08 08:57
parse 12,45  : 1245
"title".ToUpper() : TITLE

Baştaki sorunun cevabı: container'da decimal.Parse("12,45") exception fırlatmıyor, sessizce 1245 dönüyor. Container'da LANG tanımlı olmadığı için CurrentCulture adı boş olan invariant culture; invariant culture'da virgül binlik ayracı, ondalık ayracı nokta. Aynı kod geliştiricinin makinesinde 12,45 TL, sunucuda 1.245 TL üretir. Türkçe I harfinin ToUpper ile "TİTLE" olması da aynı ailenin üyesi; ayrıntısı C# Temelleri serisinin "C# String'ler: Interpolation, StringBuilder ve Türkçe I Tuzağı" yazısında. DateTime.Now ise iki ortamda tam üç saat farklı.

Ortam farklarını kontrol ederken baktığım liste şu:

FarkTipik belirtiNasıl kontrol edilir
CultureParse edilen sayı 100 kat büyük, büyük harfe çevrilmiş key eşleşmiyorCultureInfo.CurrentCulture'ı log'a yaz, testi iki culture'da koş
TimezoneRapor 3 saat kayık, gece yarısı başlayan kampanya sunucuda başlamamışTimeZoneInfo.Local'ı log'a yaz, TimeProvider ile zamanı sabitle
Configuration ve feature flagYalnız bir müşteri grubunda ya da bir ortamda görünen davranışEtkin configuration'ı dök (GetDebugView()), flag değerlerini karşılaştır
VeriYalnız belirli bir ürün, fiyat ya da müşterideAynı veriyi anonimleştirip kendi ortamına al
SürümDüzeltildi sanılan bug devam ediyorHealth endpoint'ten sürüm ve commit hash'ini oku
Tarayıcı ve cacheBazı kullanıcılar eski ekranı görüyorGizli pencere, Network panelinde cache'i kapatmak, tarayıcı sürümü

Bu Vakada Ortam mı, Kod mu?

Yukarıdaki çıktıyı görünce akla hemen bir hipotez geliyor: "Production container'da, culture invariant; belki yuvarlama da culture'a göre değişiyordur." Güzel bir hipotez, çünkü "bazen" kelimesini de açıklayabilir: belki bazı sunucular farklı culture ile çalışıyordur. Ama hipotez, tahmini sınanana kadar hipotezdir. Tahmin şu: yuvarlama culture'a bağlıysa, aynı test farklı culture'larda farklı sonuç vermeli.

Deneyi yapmak için önce hatayı bir test olarak yazmamız gerekiyor; testi aşağıda ayrıntılı göreceğiz. Şimdilik sonucunu iki culture'da çalıştıralım:

$ LANG=tr_TR.UTF-8 dotnet test | grep -E "^failed|Expected|Actual|failed:"
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12,45, discountRate: 0,1, expected: 11,21) (0ms)
  Expected: 11,21
  Actual:   11,20
  failed: 1

$ LANG=en_US.UTF-8 dotnet test | grep -E "^failed|Expected|Actual|failed:"
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12.45, discountRate: 0.1, expected: 11.21) (0ms)
  Expected: 11.21
  Actual:   11.20
  failed: 1

Değerlerin yazılışı culture'a göre değişiyor (12,45 ve 12.45), ama sonuç değişmiyor: iki ortamda da beklenen 11,21, gelen 11,20. Culture hipotezi elendi; günlüğe bir satır daha. decimal aritmetiği ve Math.Round culture'dan bağımsızdır, culture yalnız string'e çevirme ve parse etme sırasında devreye girer. Bu vakada ortam farkı sebep değil; ama checklist'i atlasaydık bunu bilemezdik.

Peki "bazen" nereden geliyor? Ortamdan değil, veriden. Önceki yazıdaki tarama, farkın yalnız ham değeri tam ortada olan ve ikinci ondalığı çift olan tutarlarda çıktığını göstermişti. Yani tetikleyici, siparişteki fiyat ve indirim kombinasyonu.

Minimal Repro: Kodu ve Veriyi Küçültmek

Elimizdeki ilk repro bir siparişti: üç satır, bir müşteri, bir kampanya, ücretsiz kargo. Minimal repro, hatayı hâlâ üreten en küçük girdi ve en küçük koddur. Küçültmenin iki faydası var: hatanın gerçekte neye bağlı olduğunu gösterir ve başka birine (ekip arkadaşına, bir kütüphanenin issue tracker'ına) beş saniyede anlatılabilir hale getirir.

Veriyi küçültelim: müşteriyi, kampanyayı ve kargoyu çıkarınca fark devam ediyor. 89,90'lık ve 45,50'lik satırları çıkarınca da devam ediyor. Geriye tek satır kalıyor: 12,45 TL, %10 indirim, 1 adet. Kodu küçültelim: OrderLine ve PriceCalculator da gerekmiyor, hata framework'ün tek bir çağrısında görünüyor:

decimal raw = 12.45m * (1 - 0.10m);
Console.WriteLine(FormattableString.Invariant($"{raw} -> default: {Math.Round(raw, 2)}, AwayFromZero: {Math.Round(raw, 2, MidpointRounding.AwayFromZero)}"));
11.2050 -> default: 11.20, AwayFromZero: 11.21

İki satırlık repro'nun söylediği şey önemli: .NET'te bir bug yok, .NET dokümante ettiği şeyi yapıyor. Math.Round(decimal, int) ortadaki değerleri en yakın çifte yuvarlar. Bug, bizim kodumuzun bu varsayılanı kullanması ve fatura servisinin başka bir kural kullanması. Minimal repro'yu bu kadar küçültmeseydik, "framework'te bir sorun var mı" sorusuyla vakit kaybedebilirdik.

Büyük bir girdiyi (5.000 satırlık bir import dosyası gibi) elle küçültmek yorucu ve hataya açık. Bunu otomatik yapan delta debugging tekniğini bu serinin 03 numaralı yazısında göreceğiz.

Flaky Testler ve Heisenbug'lar

Her bug bu kadar uslu değil. Bazıları aynı girdiyle bazen olur, bazen olmaz. Sebep çoğu zaman zamanlamadır: iki thread'in sırası, bir timeout'un sınırı, bir cache'in dolu ya da boş olması, saatin gece yarısını geçmesi. Böyle bir hatayı reproduce etmenin yolu, değişkeni sabitlemektir. Zamana bağlıysa zamanı TimeProvider ile inject edip testte sabitle; rastgeleliğe bağlıysa seed'i sabitle; sıraya bağlıysa testi yüzlerce kez döngüde koşup oranı ölç.

Heisenbug, Heisenberg'in belirsizlik ilkesine gönderme yapan bir terim: gözlemlemeye çalıştığında davranışı değişen bug. Debugger'da breakpoint'te durduğunda iki thread arasındaki yarış ortadan kalkar; Debug build'de optimizasyon kapalı olduğu için Release'te görülen bir davranış kaybolur; Watch penceresinin bir property'yi değerlendirmesi, o property'nin getter'ındaki lazy initialization'ı tetikleyip durumu değiştirir. Böyle bir hata debugger'da kayboluyorsa, gözlemi daha az müdahaleci araçlara taşı: log, tracepoint, dump. Bu serinin 06 numaralı yazısı async ve multi-thread debugging'i, 09 ve 10 numaralı yazıları dump ve trace ile teşhisi anlatıyor.

Önce Kırmızı Test

Minimal repro bir console satırı olarak kalırsa, düzeltme yapıldıktan sonra kaybolur gider. Onu kalıcı hale getirmenin yolu, bir test olarak yazmaktır. Sıra önemli: test, düzeltmeden önce yazılır ve önce kırmızı görülür. Kırmızı görmediğin bir test, bug'ı yakaladığını kanıtlamaz; belki yanlış method'u çağırıyordur, belki assertion hiç çalışmıyordur. xUnit'in temellerini C# İleri serisinin "C#'ta Unit Test: xUnit, Theory/InlineData ve Test Edilebilir Kod" yazısında anlatmıştık; burada yalnız bu vakaya uyguluyoruz:

using Bookstore.Ordering;
using Xunit;

namespace Bookstore.Ordering.Tests;

public class PriceCalculatorTests
{
    // Ticket #4127: #10482 numaralı sipariş 178.46'dan tahsil edildi, fatura 178.47
    [Theory]
    [InlineData(12.45, 0.10, 11.21)]
    [InlineData(45.50, 0.15, 38.68)]
    [InlineData(89.90, 0.00, 89.90)]
    public void DiscountedUnitPrice_rounds_midpoint_away_from_zero(
        decimal unitPrice, decimal discountRate, decimal expected)
    {
        OrderLine line = new("978-605-0000-01-1", unitPrice, 1, discountRate);

        decimal actual = PriceCalculator.DiscountedUnitPrice(line);

        Assert.Equal(expected, actual);
    }
}

Üç satırın üçü de ticket'tan: hatayı gösteren 12,45, tam ortada olup doğru sonuç veren 45,50 ve indirimsiz 89,90. Son ikisi bugün de yeşil; görevleri, düzeltmenin onları bozmadığını göstermek. Test projesi xUnit v3 kullanıyor; xUnit v3 testleri Microsoft.Testing.Platform üzerinde çalışır. .NET 10 SDK'da (Software Development Kit) dotnet test, bu platformu ancak solution kökündeki global.json'da runner olarak seçersen çalıştırıyor; seçmezsen build "Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later." hatasıyla duruyor:

{
  "sdk": {
    "version": "10.0.402",
    "rollForward": "latestFeature"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

Test, düzeltmeden önce:

$ dotnet test
Running tests from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
failed Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(unitPrice: 12.45, discountRate: 0.1, expected: 11.21) (0ms)
  from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
  Assert.Equal() Failure: Values differ
  Expected: 11.21
  Actual:   11.20
    at Bookstore.Ordering.Tests.PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(Decimal unitPrice, Decimal discountRate, Decimal expected) in ./tests/Bookstore.Ordering.Tests/PriceCalculatorTests.cs:20
    at InvokeStub_PriceCalculatorTests.DiscountedUnitPrice_rounds_midpoint_away_from_zero(Object, Span`1)
    at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) failed with 1 error(s) (519ms)
Exit code: 2
Test run summary: Failed!
  total: 3
  failed: 1
  succeeded: 2
  skipped: 0
  duration: 626ms
Test run completed with non-success exit code: 2 (see: https://aka.ms/testingplatform/exitcodes)

Kırmızı, ve doğru sebepten kırmızı: beklenen 11,21, gelen 11,20. Şimdi düzeltme tek bir argüman:

public static decimal DiscountedUnitPrice(OrderLine line) =>
    Math.Round(line.UnitPrice * (1 - line.DiscountRate), 2, MidpointRounding.AwayFromZero);
$ dotnet test
Running tests from ./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
./tests/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64) passed (502ms)
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 598ms

Yeşil. Agans'ın dokuzuncu kuralını tamamlamak için düzeltmeyi geri al ve testin tekrar kırmızıya döndüğünü gör. Bu test artık bir regression test: bu satır bir gün tekrar değişirse, CI (Continuous Integration) pipeline'ı merge'den önce kırmızıya döner.

Angular tarafında yöntem aynı. Bir component'in ya da service'in yanlış davranışı, Angular'ın yeni projelerde varsayılan test runner olarak kurduğu Vitest ile önce kırmızı bir spec olarak yazılır, sonra düzeltilir. Frontend'de bug avı bu serinin 07 numaralı yazısında.

Bir not: bu test bug'ı yakaladı ama hâlâ bir soruyu cevaplamıyor. Bu satır ne zaman, hangi değişiklikle böyle oldu? Çünkü muhasebe aynı farkı önceki haftaların mutabakatlarında hiç görmemişti. O soruyu bir sonraki yazıda git bisect run ile cevaplayacağız.

Üretimde Dikkat

Production verisini reproduce için kendi makinene alırken, kişisel veriyi anonimleştir. Sipariş satırlarındaki fiyat ve indirim oranları bu bug için yeterli; müşterinin adı, adresi ve e-posta adresi gerekmiyor. Gerekenden fazla veri almak, KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) açısından da gereksiz risk.

Hata production'da olup hiçbir şekilde reproduce edilemiyorsa, bir sonraki gerçekleşmeyi yakalayacak gözlemi ekle: ilgili yerde ham değerleri, culture'ı, timezone'u ve sürümü yazan structured bir log satırı. Bu bir düzeltme değil, bir deney; bir sonraki ticket geldiğinde elinde veri olur. Log'larla teşhis bu serinin 08 numaralı yazısında, log üretmenin kendisi ise ilerideki .NET'te Logging serisinde.

Culture ve timezone'u ortama bırakma. Sunucu tarafında para ve tarih işlemlerinde CultureInfo.InvariantCulture ve UTC kullan; kullanıcıya gösterirken kullanıcının culture'ına ve timezone'una çevir. Container image'larında bu değerleri bilinçli olarak sabitle ki makine değiştiğinde davranış değişmesin.

Sık Düşülen Tuzaklar

En yaygın tuzak, "bende çalışıyor" diyerek ticket'ı kapatmak. Senin makinende çalışması, raporlayanın ortamında ya da verisinde çalıştığı anlamına gelmez. Bu vakada 12,40 TL'lik bir kitapla deneyen biri "her şey doğru" der. "Reproduce edemedim" bir sonuç değil, bir durum raporudur; ardından gelmesi gereken soru "aramızdaki fark ne?" olmalı.

İkinci tuzak, testi düzeltmeden sonra yazmak. Düzeltmeden sonra yazılan test ilk koşuda yeşil olur ve bu yeşil hiçbir şey kanıtlamaz; testin bug'ı gerçekten yakalayıp yakalamadığını bilemezsin.

Üçüncü tuzak, testin beklenen değerini kodun ürettiği değerden kopyalamak. Testteki 11,21 faturadan, yani iş kuralından geliyor; kodu çalıştırıp çıkan 11,20'yi teste yazsaydık, bug'ı testle koruma altına almış olurduk.

Dördüncü tuzak, minimal repro'yu yeterince küçültmemek. Üç satırlık siparişle kalsaydık, kargo ya da kampanya kodunu da şüpheli listesinde tutmaya devam edecektik.

Beşinci tuzak, HAR dosyasını ya da production log'unu olduğu gibi ticket'a eklemek. Ticket sistemleri genellikle şirketin yarısına açıktır; içinde token olan bir dosya orada kalıcı bir güvenlik açığıdır.

Küçük Bir Deneme

★☆☆ Baştaki ortam kontrol programını kendi makinende ve bir container'da çalıştır. Hangi satırlar farklı çıktı? decimal.Parse("12,45", CultureInfo.InvariantCulture) ve decimal.Parse("12,45", new CultureInfo("tr-TR")) çağrılarını ekleyip iki ortamda da aynı sonucu aldığını doğrula.

★★☆ PriceCalculatorTests'e, %15 indirimde ayrışan bir fiyat ekle. Hangi fiyatın ayrışacağını önce kâğıt üstünde hesapla, sonra testin kırmızı olduğunu gör.

★★★ Bir kampanyanın İstanbul saatiyle gece yarısı başladığını düşün. DateTime.Now kullanan bir Campaign.IsActive() method'u yaz, sonra onu TimeProvider alacak şekilde değiştir ve UTC 21:30 ile İstanbul 00:30'un aynı an olduğunu gösteren bir test yaz.

Bir sonraki yazıda bu kırmızı testi bir dedektif gibi kullanacağız: git bisect run ile 31 commit'lik bir aralıkta hatayı ilk getiren commit'i yedi denemede bulacak, derlenmeyen bir commit'i nasıl atlayacağımızı görecek ve 5.000 satırlık bir import dosyasını delta debugging ile üç satıra indireceğiz.