.NET’te Test Yazmanın Tam Rehberi — Bölüm 1: xUnit + Moq ile Sıfırdan Profesyonel Testler
Önce Şunu Soralım: Neden Test Yazıyoruz?
.NET’te Test Yazmanın Tam Rehberi — Bölüm 1: xUnit + Moq ile Sıfırdan Profesyonel Testler
Önce Şunu Soralım: Neden Test Yazıyoruz?
Düşünün, bir e-ticaret sitesinde çalışıyorsunuz. Ödeme sistemine küçük bir değişiklik yaptınız. Her şey normal görünüyor, kodu canlıya aldınız. Gece 2'de telefon çalıyor: “Ödemeler geçmiyor.”
İşte birim testleri tam olarak bu senaryoyu engeller. Kodu canlıya almadan önce, yazdığınız her parçanın beklediğiniz gibi çalıştığını otomatik olarak doğrular.
Test yazmanın size kazandırdıkları:
- Yeni özellik eklerken eski kodu bozmadığınızı anlık görürsünüz
- “Bu metot ne yapıyor?” sorusunun cevabı test dosyasında yazılıdır
- Refactoring yaparken kendinize güvenirsiniz
- Sabah 2'de telefon açılmaz
Bu Makalede Ne Öğreneceksiniz?
Bu makale hiç test yazmamış biri için tasarlandı. En basit testten başlayıp adım adım ilerleyeceğiz:
- İlk test — sadece bir metot çağırıp sonucu kontrol etmek
- Parametreli testler — aynı testi farklı verilerle tekrar etmek
- Exception testleri — “hata bekliyorum” demek
- Mock nedir, neden lazım? — bağımlılıkları simüle etmek
- Moq kütüphanesi — tüm detaylarıyla
- Gerçek dünya senaryosu — her şeyi bir araya getirmek
Kurulum
# Yeni bir test projesi oluştur
dotnet new xunit -n MyProject.Tests
# Moq ve okunabilir assertion kütüphanesi ekle
dotnet add package Moq
dotnet add package FluentAssertions
Projenizin .csproj dosyası şöyle görünmeli:
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.x.x" />
<PackageReference Include="xunit" Version="2.x.x" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.x.x" />
<PackageReference Include="Moq" Version="4.x.x" />
<PackageReference Include="FluentAssertions" Version="6.x.x" />
</ItemGroup>
Testleri çalıştırmak için terminale şunu yazmanız yeterli:
dotnet test
BÖLÜM 1 — İlk Adım: En Basit Test
Test Nasıl Çalışır? AAA Deseni
Her iyi test üç bölümden oluşur. Bu desene AAA denir:
- Arrange (Hazırla): Test için gerekli nesneleri oluştur, değişkenleri tanımla
- Act (Çalıştır): Test etmek istediğin metodu çağır
- Assert (Doğrula): Sonucun beklediğin gibi olduğunu kontrol et
Bu üç bölümü kodda her zaman yorum satırıyla ayırın. Hem sizi hem de sonra kodu okuyacak kişiyi çok kurtarır.
[Fact] — Tekil Test
[Fact] attribute'u xUnit'e "bu bir test metodudur" der. En temel test türüdür. Parametre almaz, her seferinde aynı şekilde çalışır.
Önce test edeceğimiz sınıfı yazalım:
// Bu, test ETTİĞİMİZ asıl kod (production kodu)
public class Hesaplama
{
public int Topla(int a, int b)
{
return a + b;
}
public int Bol(int a, int b)
{
if (b == 0)
throw new DivideByZeroException("Sıfıra bölme yapılamaz.");
return a / b;
}
}
Şimdi bu sınıf için test yazalım:
// Bu, test YAZAN dosya (test projesi)
public class HesaplamaTests
{
[Fact]
public void Topla_IkiPozitifSayi_DogruSonucDonmeli()
{
// Hazırla: test için gerekli nesneyi oluştur
var hesaplama = new Hesaplama();
// Çalıştır: test etmek istediğimiz metodu çağır
int sonuc = hesaplama.Topla(3, 4);
// Doğrula: sonucun beklediğimiz gibi olduğunu kontrol et
Assert.Equal(7, sonuc);
}
}
Test adı üç parçadan oluşur: [TestEdilenMetot]_[Senaryo]_[BeklenenSonuc]
Bu isimlendirme kuralı çok önemlidir. Test başarısız olduğunda, ismine bakarak ne test ettiğinizi ve neyin bozulduğunu anlarsınız.
Assert Metotları — Sonucu Nasıl Doğrularız?
Assert, xUnit'in "kontrol et ve sonuç çıkar" aracıdır. Temel metotları şunlardır:
// Değer eşit mi?
Assert.Equal(7, sonuc);
Assert.NotEqual(0, sonuc);
// Nesne null mu, değil mi?
Assert.Null(nesne);
Assert.NotNull(nesne);
// Koşul doğru mu, yanlış mı?
Assert.True(sonuc > 0);
Assert.False(sonuc < 0);
// Liste boş mu, değil mi?
Assert.Empty(liste);
Assert.NotEmpty(liste);
// Liste belirli bir eleman içeriyor mu?
Assert.Contains(5, liste);
Assert.DoesNotContain(99, liste);
// Nesne belirli bir tipte mi?
Assert.IsType<string>(nesne);
// Değer belirli bir aralıkta mı? (1 ile 100 arasında)
Assert.InRange(deger, 1, 100);
// Metin belirli bir ifadeyle başlıyor/bitiyor mu?
Assert.StartsWith("Merhaba", metin);
Assert.EndsWith("Dünya", metin);
Assert.Contains("xunit", metin);
BÖLÜM 2 — Parametreli Testler
[Theory] + [InlineData] — Aynı Testi Farklı Verilerle Çalıştırmak
[Theory]
[InlineData(3, 4, 7)] // 3 + 4 = 7 olmalı
[InlineData(0, 0, 0)] // 0 + 0 = 0 olmalı
[InlineData(-5, 5, 0)] // -5 + 5 = 0 olmalı
[InlineData(100, 200, 300)] // 100 + 200 = 300 olmalı
public void Topla_FarkliGirdiler_DogruSonucDonmeli(int a, int b, int expectedResult)
{
// Hazırla
var hesaplama = new Hesaplama();
// Çalıştır
int sonuc = hesaplama.Topla(a, b);
// Doğrula
Assert.Equal(expectedResult, sonuc);
}
Bu tek metot, dotnet test çalıştırıldığında 4 ayrı test olarak görünür. Biri başarısız olsa bile diğerleri çalışmaya devam eder.
[MemberData] — Veriyi Ayrı Bir Yerden Almak
[InlineData] yalnızca basit değerler (int, string, bool) kabul eder. Karmaşık nesneler veya çok sayıda veri varsa [MemberData] kullanırsınız.
public class UrunFiyatTests
{
// Test verilerini ayrı bir property'de tanımlıyoruz
// IEnumerable<object[]> döndürmesi zorunlu
public static IEnumerable<object[]> KdvVerileri =>
new List<object[]>
{
// { netFiyat, kdvOrani, beklenenToplamFiyat }
new object[] { 100m, 0.18m, 118m },
new object[] { 200m, 0.08m, 216m },
new object[] { 0m, 0.18m, 0m },
new object[] { 1000m, 0.20m, 1200m }
};
[Theory]
[MemberData(nameof(KdvVerileri))] // nameof ile string hatası önlenir
public void KdvHesapla_NetFiyat_DogruToplamFiyatDonmeli(
decimal netPrice,
decimal vatRate,
decimal expectedTotal)
{
// Hazırla
var vergiHesaplayici = new VergiHesaplayici();
// Çalıştır
decimal toplamFiyat = vergiHesaplayici.KdvEkle(netPrice, vatRate);
// Doğrula
Assert.Equal(expectedTotal, toplamFiyat);
}
}
[ClassData] — Veriyi Tamamen Ayrı Bir Sınıfa Taşımak
Test verisi büyüdükçe ya da birden fazla test sınıfında kullanılacaksa, veriyi kendi sınıfına taşımak en temiz çözümdür:
// Veriyi taşıyan sınıf — IEnumerable<object[]> implement etmeli
public class EmailDogrulamaVerileri : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { "ahmet@test.com", true }; // geçerli email
yield return new object[] { "gecersiz-email", false }; // @ işareti yok
yield return new object[] { "", false }; // boş string
yield return new object[] { "a@b.c", true }; // minimum geçerli
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class EmailDogrulamaTests
{
[Theory]
[ClassData(typeof(EmailDogrulamaVerileri))]
public void EmailDogrula_FarkliGirdiler_DogruSonucDonmeli(
string email,
bool expectedResult)
{
var dogrulay = new EmailDogrulayici();
bool sonuc = dogrulay.Gecerli(email);
Assert.Equal(expectedResult, sonuc);
}
}
BÖLÜM 3 — Hata Testleri
Exception Testleri — “Bu Hata Fırlamalı” Demek
Bazen bir metodun hata fırlatmasını bekliyoruz. Örneğin sıfıra bölme işleminde DivideByZeroException fırlatılmalıdır. Bunu test etmek için Assert.Throws kullanırız.
[Fact]
public void Bol_SifiraBolunce_DivideByZeroExceptionFirlatmali()
{
// Hazırla
var hesaplama = new Hesaplama();
// Çalıştır ve Doğrula
// Lambda içindeki kod çalıştığında DivideByZeroException fırlatılmazsa test başarısız olur
Assert.Throws<DivideByZeroException>(() => hesaplama.Bol(10, 0));
}
Hata mesajını da kontrol etmek istiyorsanız:
[Fact]
public void Bol_SifiraBolunce_DogruHataMesajiIleException()
{
var hesaplama = new Hesaplama();
// Throws metodunun döndürdüğü exception nesnesini yakalıyoruz
var exception = Assert.Throws<DivideByZeroException>(
() => hesaplama.Bol(10, 0)
);
// Hata mesajının doğru olduğunu da kontrol ediyoruz
Assert.Equal("Sıfıra bölme yapılamaz.", exception.Message);
}
Async metotlarda ise ThrowsAsync kullanılır:
[Fact]
public async Task OdemeYap_GecersizKart_PaymentExceptionFirlatmali()
{
var servis = new OdemeServisi();
await Assert.ThrowsAsync<PaymentException>(
() => servis.OdemeYapAsync(kartNo: "0000-0000-0000-0000", tutar: 100)
);
}
BÖLÜM 4 — Mock Nedir? Neden Lazım?
Bu bölüm makalenin en kritik kısmı. Mock kavramını anlamadan Moq kütüphanesini kullanmak mümkün değil.
Sorun: Bağımlılıklar
Gerçek hayat kodlarında bir sınıf nadiren yalnız çalışır. Bir SiparisServis düşünün:
public class SiparisServis
{
public Siparis Olustur(int urunId, int adet)
{
// 1. Veritabanından ürünü çek
var db = new VeritabaniContext();
var urun = db.Urunler.Find(urunId);
// 2. Stok kontrol servisini çağır
var stokServis = new StokServis();
bool stokVar = stokServis.Kontrol(urunId, adet);
// 3. E-posta gönder
var emailServis = new EmailServis();
emailServis.Gonder("siparis@ornek.com", "Siparişiniz alındı");
return new Siparis { /* ... */ };
}
}
Bu kodu test etmek istediğinizde şu sorunlarla karşılaşırsınız:
- Veritabanı: Test sırasında gerçek bir veritabanı bağlantısı gerekiyor. Yavaş, kırılgan, temizlenmesi gerekiyor.
- E-posta servisi: Her testte gerçekten e-posta mı gönderelim? Hem yavaş hem saçma.
- Harici API: İnternet bağlantısı yoksa test çalışmaz.
- Tahmin edilemezlik: Veritabanında hangi verinin olduğunu bilemezsiniz.
Test etmek istediğiniz şey SiparisServis'in kendi mantığı, yoksa veritabanının veya e-posta servisinin çalışması değil.
Çözüm: Dependency Injection + Interface
Önce bağımlılıkları dışarıdan enjekte edilebilir hale getiririz. Bunun için interface kullanırız:
// Her bağımlılık için bir interface tanımlıyoruz
public interface IUrunRepository
{
Urun? GetirById(int id);
}
public interface IStokServis
{
bool StokVarMi(int urunId, int adet);
}
public interface IEmailServis
{
void Gonder(string alici, string mesaj);
}
// Artık SiparisServis bağımlılıklarını dışarıdan alıyor
public class SiparisServis
{
private readonly IUrunRepository _urunRepository;
private readonly IStokServis _stokServis;
private readonly IEmailServis _emailServis;
// Constructor injection: bağımlılıklar dışarıdan veriliyor
public SiparisServis(
IUrunRepository urunRepository,
IStokServis stokServis,
IEmailServis emailServis)
{
_urunRepository = urunRepository;
_stokServis = stokServis;
_emailServis = emailServis;
}
public Siparis Olustur(int urunId, int adet)
{
var urun = _urunRepository.GetirById(urunId)
?? throw new ArgumentException($"Ürün bulunamadı: {urunId}");
if (!_stokServis.StokVarMi(urunId, adet))
throw new StokYetersizException("Yeterli stok bulunmuyor.");
_emailServis.Gonder("siparis@ornek.com", $"Siparişiniz alındı: {urun.Ad}");
return new Siparis
{
UrunId = urunId,
Adet = adet,
ToplamFiyat = urun.Fiyat * adet
};
}
}
Artık SiparisServis, somut sınıflara değil interface'lere bağlı. Bu sayede test sırasında gerçek IUrunRepository yerine sahte (mock) bir IUrunRepository verebiliriz.
Mock Nedir? Bir Benzetme
Mock nesneyi şöyle düşünün: Bir aktör film setinde gerçek bir araba kullanmak yerine sahte bir araba kullanır. Sahte araba tam da gerçeği gibi görünür, kapılar açılır, direksiyon döner — ama motoru yoktur.
Mock da böyledir: Gerçek IUrunRepository yerine "id=1 gelirse şu ürünü döndür" diyebileceğiniz sahte bir nesne oluşturursunuz. Veritabanına bağlanmaz, ağ çağrısı yapmaz — ama arayüzün tüm metodlarına sahiptir.
BÖLÜM 5 — Moq Kütüphanesi: Tüm Detaylarıyla
Mock Oluşturma ve Kullanma
// Mock nesnesini oluştur
var mockRepo = new Mock<IUrunRepository>();
// Mock nesnesini sisteme ver
// .Object ile gerçek interface nesnesini alırsınız
var servis = new SiparisServis(mockRepo.Object, ...);
Mock<T> sınıfının iki önemli parçası:
mock(Mock nesnesi): Davranışları tanımlamak ve doğrulama yapmak içinmock.Object(T nesnesi): Servisinize gerçek nesne olarak vermek için
Setup — "Şu Metot Çağrılırsa, Şunu Yap" Demek
Setup, mock nesnesine bir davranış tanımlar. "Bu metot şu parametreyle çağrılırsa, şu değeri döndür" demektir.
// GetirById(1) çağrılırsa → bu ürünü döndür
mockRepo.Setup(repo => repo.GetirById(1))
.Returns(new Urun { Id = 1, Ad = "Laptop", Fiyat = 25000 });
Buradaki repo => ifadesi bir lambda expression'dır. "repo değişkenini kullanarak şu metodu çağırdığında..." demektir. repo'nun adı önemli değil, r veya x de yazabilirdiniz.
It Sınıfı — "Herhangi Bir Değer" Demek
It Moq'un en önemli kavramlarından biridir. Türkçeye "O" veya "Herhangi biri" olarak çevrilebilir. Argüman eşleştirici olarak görev yapar.
Sorun şu: Setup(r => r.GetirById(1)) yazarsanız, bu setup yalnızca GetirById(1) çağrısında devreye girer. GetirById(2) veya GetirById(999) için geçersizdir.
Peki ya “hangi id gelirse gelsin, aynı şeyi yap” demek istiyorsak? İşte It burada devreye girer:
// Sadece 1 parametresi için çalışır
mockRepo.Setup(r => r.GetirById(1))
.Returns(new Urun { Ad = "Laptop" });
// Herhangi bir int değer için çalışır - It.IsAny<int>()
mockRepo.Setup(r => r.GetirById(It.IsAny<int>()))
.Returns(new Urun { Ad = "Test Ürünü" });
It sınıfının tüm metotları:
// ─── It.IsAny<T>() ──────────────────────────────────────────────
// "Bu parametreye herhangi bir T değer gelebilir" demektir.
// Parametrenin ne olduğu önemli değilse kullanılır.
mockRepo.Setup(r => r.GetirById(It.IsAny<int>()))
.Returns(new Urun());
mockServis.Setup(s => s.Gonder(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
// ─── It.Is<T>(koşul) ────────────────────────────────────────────
// "Bu parametre şu koşulu sağlamalı" demektir.
// Belirli bir kurala uyan değerler için kullanılır.
mockRepo.Setup(r => r.GetirById(It.Is<int>(id => id > 0)))
.Returns(new Urun { Ad = "Geçerli Ürün" });
mockRepo.Setup(r => r.GetirById(It.Is<int>(id => id > 0 && id < 1000)))
.Returns(new Urun { Ad = "Küçük ID'li Ürün" });
// Birden fazla koşul - string için örnek
mockServis.Setup(s => s.Ara(It.Is<string>(q => q.Length > 3 && !q.Contains(" "))))
.Returns(new List<Urun>());
// ─── It.IsIn<T>(değerler) ────────────────────────────────────────
// "Bu parametre şu listedeki değerlerden biri olmalı" demektir.
mockRepo.Setup(r => r.GetirById(It.IsIn(1, 2, 3)))
.Returns(new Urun { Ad = "Listede Olan Ürün" });
// ─── It.IsNotIn<T>(değerler) ─────────────────────────────────────
// "Bu parametre şu listedeki değerlerden biri OLMAMALI" demektir.
mockRepo.Setup(r => r.GetirById(It.IsNotIn(0, -1, -2)))
.Returns(new Urun { Ad = "Geçerli Ürün" });
// ─── It.IsInRange<T>(min, max, aralık türü) ──────────────────────
// "Bu parametre şu aralıkta olmalı" demektir.
// Range.Inclusive: min ve max dahil | Range.Exclusive: min ve max hariç
mockRepo.Setup(r => r.GetirById(It.IsInRange(1, 100, Range.Inclusive)))
.Returns(new Urun { Ad = "Aralıkta Ürün" });
// ─── It.IsRegex(desen) ───────────────────────────────────────────
// "Bu string parametre şu regex deseniyle eşleşmeli" demektir.
// Sadece string parametreler için kullanılır.
mockEmailServis.Setup(e => e.Gonder(It.IsRegex(@"^[\w-]+@[\w-]+\.\w+$"), It.IsAny<string>()))
.Returns(true);
// ─── It.IsNull<T>() ve It.IsNotNull<T>() ─────────────────────────
// Parametrenin null veya null olmayan olmasını bekler.
mockRepo.Setup(r => r.Ekle(It.IsNotNull<Urun>()))
.Verifiable();
Returns ve ReturnsAsync — Dönüş Değeri Tanımlamak
// Sabit bir değer döndür
mockRepo.Setup(r => r.GetirById(1))
.Returns(new Urun { Id = 1, Ad = "Laptop" });
// null döndür (ürün bulunamadı senaryosu)
mockRepo.Setup(r => r.GetirById(999))
.Returns((Urun?)null);
// Async metotlar için ReturnsAsync kullanılır
mockRepo.Setup(r => r.GetirByIdAsync(1))
.ReturnsAsync(new Urun { Id = 1, Ad = "Laptop" });
// Her çağrıda farklı değer döndür (sırayla)
mockRepo.SetupSequence(r => r.GetirById(It.IsAny<int>()))
.Returns(new Urun { Ad = "Birinci Çağrıda Bu Döner" })
.Returns(new Urun { Ad = "İkinci Çağrıda Bu Döner" })
.Returns(null); // Üçüncü çağrıda null döner
Throws — Hata Fırlatmasını Simüle Etmek
Veritabanı bağlantı hatası, ağ zaman aşımı gibi senaryoları simüle etmek için kullanılır:
// Metot çağrıldığında exception fırlat
mockRepo.Setup(r => r.GetirById(It.IsAny<int>()))
.Throws(new Exception("Veritabanı bağlantısı kesildi."));
// Async metot için
mockRepo.Setup(r => r.GetirByIdAsync(It.IsAny<int>()))
.ThrowsAsync(new TimeoutException("İstek zaman aşımına uğradı."));
// Belirli bir parametreye özel hata
mockRepo.Setup(r => r.GetirById(-1))
.Throws(new ArgumentException("ID negatif olamaz."));
Callback — Metot Çağrıldığında Yan Etki Üretmek
Bir metot çağrıldığında sadece değer döndürmekle kalmayıp, aynı zamanda bir şey kaydetmek veya bir yan etki üretmek isteyebilirsiniz:
// Ekle() çağrıldığında kaydı takip et
var eklenenUrunler = new List<Urun>();
mockRepo.Setup(r => r.Ekle(It.IsAny<Urun>()))
.Callback<Urun>(urun =>
{
// Her Ekle() çağrısında bu lambda çalışır
eklenenUrunler.Add(urun);
});
// Şimdi servisi çalıştır
servis.TopluEkle(new[] { urun1, urun2 });
// Callback sayesinde hangi ürünlerin eklendiğini görebiliyoruz
Assert.Equal(2, eklenenUrunler.Count);
Assert.Contains(eklenenUrunler, u => u.Ad == "Laptop");
Verify — Metodun Gerçekten Çağrıldığını Doğrulamak
Assert metodun sonucunu doğrularken, Verify metodun çağrılıp çağrılmadığını doğrular. "Bu metot beklediğim şekilde çağrıldı mı?" sorusunun cevabıdır.
// ─── Times.Once ─────────────────────────────────────────────────
// Metot tam olarak 1 kez çağrılmış mı?
mockEmailServis.Verify(
e => e.Gonder(It.IsAny<string>(), It.IsAny<string>()),
Times.Once
);
// ─── Times.Never ────────────────────────────────────────────────
// Metot hiç çağrılmamış mı?
// Hata durumunda email gönderilmemeli senaryosunda kullanılır.
mockEmailServis.Verify(
e => e.Gonder(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
// ─── Times.Exactly(n) ────────────────────────────────────────────
// Metot tam olarak n kez çağrılmış mı?
mockRepo.Verify(r => r.Ekle(It.IsAny<Urun>()), Times.Exactly(3));
// ─── Times.AtLeastOnce ──────────────────────────────────────────
// Metot en az 1 kez çağrılmış mı?
mockRepo.Verify(r => r.Kaydet(), Times.AtLeastOnce);
// ─── Times.AtLeast(n) ────────────────────────────────────────────
// Metot en az n kez çağrılmış mı?
mockRepo.Verify(r => r.Kaydet(), Times.AtLeast(2));
// ─── Times.AtMost(n) ─────────────────────────────────────────────
// Metot en fazla n kez çağrılmış mı?
mockRepo.Verify(r => r.Kaydet(), Times.AtMost(5));
// ─── Times.Between(min, max, aralık türü) ────────────────────────
// Metot min ile max arasında kaç kez çağrılmış?
mockRepo.Verify(r => r.Kaydet(), Times.Between(1, 3, Range.Inclusive));
Belirli parametrelerle çağrılıp çağrılmadığını da doğrulayabilirsiniz:
// Ekle() metodu, Adı "Laptop" olan bir ürünle çağrılmış mı?
mockRepo.Verify(
r => r.Ekle(It.Is<Urun>(u => u.Ad == "Laptop")),
Times.Once
);
// Email servisi, içinde "sipariş" geçen bir mesajla çağrılmış mı?
mockEmailServis.Verify(
e => e.Gonder(It.IsAny<string>(), It.Is<string>(m => m.Contains("sipariş"))),
Times.Once
);
MockBehavior — Mock'un Genel Davranışı
Moq’da iki farklı mock davranışı vardır:
// ─── Loose (varsayılan) ──────────────────────────────────────────
// Setup edilmemiş metotlar varsayılan değer döndürür (null, 0, false).
// Esnek ama dikkatsiz kullanımda hataları gizleyebilir.
var looseMock = new Mock<IUrunRepository>();
// GetirById() için setup yapılmadıysa null döner, exception fırlatmaz
// ─── Strict ──────────────────────────────────────────────────────
// Setup edilmemiş HER metot çağrısı exception fırlatır.
// Daha dikkatli ama daha güvenli.
var strictMock = new Mock<IUrunRepository>(MockBehavior.Strict);
// strictMock üzerinde her kullanılacak metot mutlaka Setup edilmeli
strictMock.Setup(r => r.GetirById(1)).Returns(new Urun());
Başlangıç için Loose (varsayılan) kullanmanız önerilir. Testleriniz olgunlaştıkça Strict'e geçmeyi düşünebilirsiniz.
BÖLÜM 6 — Test Organizasyonu ve İleri Özellikler
Fixture — Ortak Kurulum Kodu
Bir test sınıfındaki tüm testler aynı nesneye ihtiyaç duyuyorsa, her testte ayrı ayrı oluşturmak yerine constructor’da bir kez oluşturabilirsiniz.
xUnit’te her test için yeni bir sınıf instance’ı oluşturulur. Bu sayede testler birbirini etkilemez.
public class UrunServisTests : IDisposable
{
// Tüm testlerin kullandığı ortak nesneler
private readonly Mock<IUrunRepository> _mockRepo;
private readonly Mock<IStokServis> _mockStok;
private readonly UrunServis _servis;
// Constructor: Her test BAŞLAMADAN ÖNCE çalışır
public UrunServisTests()
{
// Mock nesnelerini bir kez oluştur
_mockRepo = new Mock<IUrunRepository>();
_mockStok = new Mock<IStokServis>();
// Servisi inject edilen mock nesneleriyle kur
_servis = new UrunServis(_mockRepo.Object, _mockStok.Object);
}
// Her test bu ortak nesneyi kullanır
[Fact]
public void GetirById_MevcutId_UrunDonmeli()
{
_mockRepo.Setup(r => r.GetirById(1))
.Returns(new Urun { Id = 1, Ad = "Laptop" });
var urun = _servis.GetirById(1);
Assert.NotNull(urun);
}
// Dispose: Her test BİTTİKTEN SONRA çalışır
// Veritabanı bağlantısı, dosya handle'ı gibi kaynakları temizlemek için
public void Dispose()
{
// Mock nesneleri için genellikle gerekmez
// Gerçek kaynaklar (DbContext, HttpClient vs.) varsa burada kapat
}
}
IClassFixture<T> — Pahalı Nesneleri Paylaşmak
Bazı nesnelerin oluşturulması pahalıdır: HTTP client, test veritabanı bağlantısı, Docker container gibi. Bunları her test için değil, tüm sınıf için bir kez oluşturmak istersiniz.
// Paylaşılacak nesneyi tutan sınıf
public class VeritabaniFixture : IDisposable
{
public AppDbContext Db { get; }
public VeritabaniFixture()
{
// Bu sınıftaki TÜM testler başlamadan önce bir kez çalışır
Db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb")
.Options);
// Test verilerini ekle
Db.Urunler.AddRange(
new Urun { Id = 1, Ad = "Laptop", Fiyat = 25000 },
new Urun { Id = 2, Ad = "Klavye", Fiyat = 500 }
);
Db.SaveChanges();
}
public void Dispose()
{
// Bu sınıftaki TÜM testler bittikten sonra bir kez çalışır
Db.Dispose();
}
}
// IClassFixture<T> ile fixture'ı sınıfa bağla
public class UrunRepositoryTests : IClassFixture<VeritabaniFixture>
{
private readonly AppDbContext _db;
// xUnit fixture'ı constructor'a enjekte eder
public UrunRepositoryTests(VeritabaniFixture fixture)
{
_db = fixture.Db;
}
[Fact]
public void GetirById_MevcutId_UrunDonmeli()
{
var urun = _db.Urunler.Find(1);
Assert.NotNull(urun);
Assert.Equal("Laptop", urun!.Ad);
}
}
Testleri Gruplama ve Filtreleme — [Trait]
Projeniz büyüdükçe yüzlerce test birikir. [Trait] ile testlere etiket ekleyebilir ve sadece belirli etiketli testleri çalıştırabilirsiniz:
[Fact]
[Trait("Katman", "Servis")]
[Trait("Özellik", "Sipariş")]
[Trait("Öncelik", "Kritik")]
public void Siparis_Olustur_BasariliOlmali() { }
[Fact]
[Trait("Katman", "Repository")]
[Trait("Özellik", "Ürün")]
public void Urun_GetirById_DonmeMali() { }
Filtreleyerek çalıştırma:
# Sadece "Katman=Servis" testlerini çalıştır
dotnet test --filter "Katman=Servis"
# Sadece "Öncelik=Kritik" testlerini çalıştır
dotnet test --filter "Öncelik=Kritik"
# Belirli bir test metodunu çalıştır
dotnet test --filter "FullyQualifiedName~SiparisServisTests"
Test Atlamak — [Fact(Skip = "...")]
Henüz tamamlanmamış bir özellik varsa veya test geçici olarak devre dışı bırakılacaksa:
[Fact(Skip = "Ödeme entegrasyonu henüz tamamlanmadı — TICKET-4521")]
public void OdemeYap_KrediKarti_BasariliOlmali()
{
// Bu test çalıştırılmaz, atlandı olarak raporlanır
}
Test Çıktısı — ITestOutputHelper
Console.WriteLine test sırasında görünmez. Bunun yerine xUnit'in kendi çıktı mekanizmasını kullanın:
public class DebugTests
{
private readonly ITestOutputHelper _output;
// xUnit ITestOutputHelper'ı otomatik enjekte eder
public DebugTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void Hesaplama_SonucuLogla()
{
var hesaplama = new Hesaplama();
_output.WriteLine("Test başlıyor...");
int sonuc = hesaplama.Topla(3, 4);
_output.WriteLine($"Hesaplama sonucu: {sonuc}");
Assert.Equal(7, sonuc);
_output.WriteLine("Test başarıyla tamamlandı.");
}
}
BÖLÜM 7 — FluentAssertions: Daha Okunabilir Testler
Standart Assert metodları işlevsel ama okunurluğu sınırlı. FluentAssertions kütüphanesi testleri neredeyse İngilizce bir cümle gibi okunur hale getirir. Üstelik test başarısız olduğunda çok daha açıklayıcı hata mesajları üretir.
using FluentAssertions;
// ─── Standart xUnit vs FluentAssertions ──────────────────────────
// xUnit
Assert.Equal("Laptop", urun.Ad);
Assert.NotNull(urun);
Assert.True(urun.Fiyat > 0);
// FluentAssertions — "urun, null olmamalı. Ad'ı 'Laptop' olmalı."
urun.Should().NotBeNull();
urun.Ad.Should().Be("Laptop");
urun.Fiyat.Should().BePositive();
// ─── Sayısal Karşılaştırmalar ─────────────────────────────────────
urun.Fiyat.Should().BeGreaterThan(0);
urun.Fiyat.Should().BeLessThanOrEqualTo(100_000);
urun.Fiyat.Should().BeInRange(1000, 50_000);
// ─── String Karşılaştırmalar ──────────────────────────────────────
urun.Ad.Should().StartWith("La");
urun.Ad.Should().EndWith("op");
urun.Ad.Should().Contain("apt");
urun.Ad.Should().HaveLength(6);
urun.Ad.Should().NotBeNullOrEmpty();
// ─── Koleksiyonlar ────────────────────────────────────────────────
urunler.Should().HaveCount(3);
urunler.Should().NotBeEmpty();
urunler.Should().Contain(u => u.Ad == "Laptop");
urunler.Should().BeInAscendingOrder(u => u.Fiyat);
urunler.Should().AllSatisfy(u => u.Fiyat.Should().BePositive());
urunler.Should().OnlyContain(u => u.Stok >= 0);
// ─── Exception ────────────────────────────────────────────────────
Action islem = () => servis.Ekle(null!);
islem.Should().Throw<ArgumentNullException>()
.WithMessage("*null*");
Func<Task> asyncIslem = () => servis.OlusturAsync(0, 0);
await asyncIslem.Should().ThrowAsync<ArgumentException>()
.WithMessage("*pozitif*");
// ─── Tarih ve Zaman ───────────────────────────────────────────────
siparis.OlusturulmaTarihi.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
siparis.OlusturulmaTarihi.Should().BeAfter(DateTime.UtcNow.AddDays(-1));
BÖLÜM 8 — Gerçek Dünya: Her Şeyi Bir Araya Getirmek
Şimdi öğrendiklerimizin hepsini bir e-ticaret senaryosunda birleştirelim.
Production Kodu
// ── Modeller ──────────────────────────────────────────────────────
public class Urun
{
public int Id { get; set; }
public string Ad { get; set; } = string.Empty;
public decimal Fiyat { get; set; }
public int Stok { get; set; }
}
public class Siparis
{
public int Id { get; set; }
public int UrunId { get; set; }
public int Adet { get; set; }
public decimal ToplamFiyat { get; set; }
public DateTime OlusturulmaTarihi { get; set; }
}
// ── Interface'ler ─────────────────────────────────────────────────
public interface IUrunRepository
{
Task<Urun?> GetirByIdAsync(int id);
}
public interface IStokServis
{
Task<bool> StokVarMiAsync(int urunId, int adet);
}
public interface IBildirimServis
{
Task BildirimGonderAsync(string alici, string mesaj);
}
// ── Özel Exception ────────────────────────────────────────────────
public class StokYetersizException : Exception
{
public StokYetersizException(string message) : base(message) { }
}
// ── Asıl Servis ───────────────────────────────────────────────────
public class SiparisServis
{
private readonly IUrunRepository _urunRepository;
private readonly IStokServis _stokServis;
private readonly IBildirimServis _bildirimServis;
public SiparisServis(
IUrunRepository urunRepository,
IStokServis stokServis,
IBildirimServis bildirimServis)
{
_urunRepository = urunRepository;
_stokServis = stokServis;
_bildirimServis = bildirimServis;
}
public async Task<Siparis> OlusturAsync(int productId, int quantity)
{
// Ürünü veritabanından getir
var urun = await _urunRepository.GetirByIdAsync(productId)
?? throw new ArgumentException($"Ürün bulunamadı: {productId}");
// Stok kontrolü yap
bool stokVar = await _stokServis.StokVarMiAsync(productId, quantity);
if (!stokVar)
throw new StokYetersizException($"'{urun.Ad}' için yeterli stok yok.");
// Siparişi oluştur
var siparis = new Siparis
{
UrunId = productId,
Adet = quantity,
ToplamFiyat = urun.Fiyat * quantity,
OlusturulmaTarihi = DateTime.UtcNow
};
// Müşteriye bildirim gönder
await _bildirimServis.BildirimGonderAsync(
"musteri@ornek.com",
$"Siparişiniz alındı: {urun.Ad} x{quantity}"
);
return siparis;
}
}
Test Kodu
public class SiparisServisTests
{
// ── Tüm testlerin ortak kullandığı mock nesneler ───────────────
private readonly Mock<IUrunRepository> _mockUrunRepo = new();
private readonly Mock<IStokServis> _mockStokServis = new();
private readonly Mock<IBildirimServis> _mockBildirim = new();
private readonly SiparisServis _servis;
public SiparisServisTests()
{
// Servisi mock nesneleriyle kur
_servis = new SiparisServis(
_mockUrunRepo.Object,
_mockStokServis.Object,
_mockBildirim.Object
);
}
// ── Senaryo 1: Her şey yolunda ────────────────────────────────
[Fact(DisplayName = "Geçerli ürün ve yeterli stok → sipariş başarıyla oluşturulur")]
public async Task Olustur_GecerliUrunVeYeterliStok_BasariliSiparisDonmeli()
{
// Hazırla
var beklenenUrun = new Urun { Id = 1, Ad = "Laptop", Fiyat = 25000 };
// "GetirByIdAsync(1) çağrılırsa → bu ürünü döndür"
_mockUrunRepo.Setup(r => r.GetirByIdAsync(1))
.ReturnsAsync(beklenenUrun);
// "StokVarMiAsync(1, 2) çağrılırsa → true döndür (stok var)"
_mockStokServis.Setup(s => s.StokVarMiAsync(1, 2))
.ReturnsAsync(true);
// "BildirimGonderAsync herhangi bir parametreyle çağrılırsa → başarıyla tamamla"
_mockBildirim.Setup(b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Çalıştır
var siparis = await _servis.OlusturAsync(productId: 1, quantity: 2);
// Doğrula
siparis.Should().NotBeNull();
siparis.UrunId.Should().Be(1);
siparis.Adet.Should().Be(2);
siparis.ToplamFiyat.Should().Be(50000m); // 25000 x 2
// Bildirim tam olarak 1 kez gönderildi mi?
_mockBildirim.Verify(
b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Once
);
}
// ── Senaryo 2: Ürün bulunamadı ────────────────────────────────
[Fact(DisplayName = "Var olmayan ürün ID'si → ArgumentException fırlatılır")]
public async Task Olustur_VarOlmayanUrun_ArgumentExceptionFirlatmali()
{
// Hazırla
// "Herhangi bir ID için → null döndür (ürün yok)"
_mockUrunRepo.Setup(r => r.GetirByIdAsync(It.IsAny<int>()))
.ReturnsAsync((Urun?)null);
// Çalıştır ve Doğrula
await Assert.ThrowsAsync<ArgumentException>(
() => _servis.OlusturAsync(productId: 999, quantity: 1)
);
// Ürün bulunamadıysa stok kontrolü yapılmamalı
_mockStokServis.Verify(
s => s.StokVarMiAsync(It.IsAny<int>(), It.IsAny<int>()),
Times.Never
);
// Bildirim de gönderilmemeli
_mockBildirim.Verify(
b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
// ── Senaryo 3: Stok yetersiz ──────────────────────────────────
[Fact(DisplayName = "Stok yetersizse → StokYetersizException fırlatılır")]
public async Task Olustur_StokYetersiz_StokYetersizExceptionFirlatmali()
{
// Hazırla
_mockUrunRepo.Setup(r => r.GetirByIdAsync(1))
.ReturnsAsync(new Urun { Id = 1, Ad = "Laptop", Fiyat = 25000 });
// "StokVarMiAsync çağrılırsa → false döndür (stok yok)"
_mockStokServis.Setup(s => s.StokVarMiAsync(1, It.IsAny<int>()))
.ReturnsAsync(false);
// Çalıştır ve Doğrula
var exception = await Assert.ThrowsAsync<StokYetersizException>(
() => _servis.OlusturAsync(productId: 1, quantity: 999)
);
// Hata mesajının doğru olduğunu kontrol et
exception.Message.Should().Contain("Laptop");
exception.Message.Should().Contain("stok");
// Stok yoksa bildirim gönderilmemeli
_mockBildirim.Verify(
b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
// ── Senaryo 4: Veritabanı hatası ──────────────────────────────
[Fact(DisplayName = "Veritabanı exception fırlatırsa → üst katmana iletilir")]
public async Task Olustur_VeritabaniHatasi_ExceptionIletilmeli()
{
// Hazırla
// "GetirByIdAsync çağrılırsa → veritabanı hatası simüle et"
_mockUrunRepo.Setup(r => r.GetirByIdAsync(It.IsAny<int>()))
.ThrowsAsync(new Exception("Veritabanı bağlantısı kesildi."));
// Çalıştır ve Doğrula
await Assert.ThrowsAsync<Exception>(
() => _servis.OlusturAsync(productId: 1, quantity: 1)
);
}
// ── Senaryo 5: Toplam fiyat hesaplama ─────────────────────────
[Theory(DisplayName = "Farklı adet ve fiyatlarda toplam fiyat doğru hesaplanır")]
[InlineData(1, 100, 100)] // 1 adet, 100 TL → 100 TL
[InlineData(2, 100, 200)] // 2 adet, 100 TL → 200 TL
[InlineData(10, 250, 2500)] // 10 adet, 250 TL → 2500 TL
[InlineData(3, 33.33, 99.99)] // Ondalıklı sayı testi
public async Task Olustur_ToplamFiyat_DogruHesaplanmali(
int quantity,
decimal unitPrice,
decimal expectedTotal)
{
// Hazırla
_mockUrunRepo.Setup(r => r.GetirByIdAsync(1))
.ReturnsAsync(new Urun { Id = 1, Ad = "Test Ürünü", Fiyat = unitPrice });
_mockStokServis.Setup(s => s.StokVarMiAsync(1, quantity))
.ReturnsAsync(true);
_mockBildirim.Setup(b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Çalıştır
var siparis = await _servis.OlusturAsync(productId: 1, quantity: quantity);
// Doğrula
siparis.ToplamFiyat.Should().Be(expectedTotal);
}
// ── Senaryo 6: Callback ile eklenen kayıtları takip etme ──────
[Fact(DisplayName = "Sipariş oluşturulurken bildirim doğru metinle gönderilir")]
public async Task Olustur_BildirimGonderilirken_DogruMesajIleGonderilmeli()
{
// Hazırla
var gonderilen = new List<(string alici, string mesaj)>();
_mockUrunRepo.Setup(r => r.GetirByIdAsync(1))
.ReturnsAsync(new Urun { Id = 1, Ad = "Laptop", Fiyat = 25000 });
_mockStokServis.Setup(s => s.StokVarMiAsync(1, 2))
.ReturnsAsync(true);
// Callback: gönderilen her bildirimi kaydet
_mockBildirim.Setup(b => b.BildirimGonderAsync(It.IsAny<string>(), It.IsAny<string>()))
.Callback<string, string>((alici, mesaj) =>
{
// Her çağrıda alıcıyı ve mesajı kaydet
gonderilen.Add((alici, mesaj));
})
.Returns(Task.CompletedTask);
// Çalıştır
await _servis.OlusturAsync(productId: 1, quantity: 2);
// Doğrula
gonderilen.Should().HaveCount(1);
gonderilen[0].mesaj.Should().Contain("Laptop");
gonderilen[0].mesaj.Should().Contain("2"); // adet bilgisi mesajda olmalı
}
}
BÖLÜM 9 — Best Practices: İyi Test Kodu Nasıl Yazılır?
Yapılması Gerekenler
- Test adı her şeyi anlatmalı
Test başarısız olduğunda, koda bakmadan neyin bozulduğunu anlayabilmelisiniz.
// ❌ Kötü — hiçbir şey söylemiyor
public void Test1() { }
public void UrunTesti() { }
// ✅ İyi — [TestedMethod]_[Scenario]_[ExpectedResult] formatı
public void GetirById_MevcutId_UrunDonmeli() { }
public void Olustur_StokYetersiz_StokYetersizExceptionFirlatmali() { }
- Her test tek bir şeyi test etmeli
// ❌ Kötü — bir testte çok fazla şey test ediliyor
[Fact]
public void SiparisServisTesti()
{
// Hem oluşturma hem stok hem bildirim test ediliyor
// Biri başarısız olunca hangisinin bozulduğunu anlamak zor
}
// ✅ İyi — her senaryo ayrı bir test
[Fact]
public void Olustur_GecerliUrun_BasariliSiparisDonmeli() { }
[Fact]
public void Olustur_StokYetersiz_ExceptionFirlatmali() { }
- Magic number yerine anlamlı değişken kullan
// ❌ Kötü
Assert.Equal(50000m, siparis.ToplamFiyat);
// ✅ İyi
const decimal BirimFiyat = 25000m;
const int Adet = 2;
const decimal BeklenenToplam = BirimFiyat * Adet; // 50000
Assert.Equal(BeklenenToplam, siparis.ToplamFiyat);
- AAA bölümlerini yorum satırıyla ayır
[Fact]
public void Hesapla_DogruSonucDonmeli()
{
// Hazırla
var servis = new HesaplamaServisi();
// Çalıştır
var sonuc = servis.Hesapla(10, 5);
// Doğrula
Assert.Equal(50, sonuc);
}
Yapılmaması Gerekenler
- Testleri birbirine bağımlı yapma
// ❌ Kötü — Test B, Test A'nın çalışmasına bağlı
public void TestA_UrunEkle() { /* ürün ekler */ }
public void TestB_UrunGetir() { /* TestA'nın eklediği ürünü getirir */ }
// ✅ Her test kendi verisini kendi oluşturmalı
- Gerçek dış servis çağırma
// ❌ Kötü — gerçek veritabanı, gerçek API, gerçek e-posta
public void Test()
{
var db = new SqlServerContext("Server=prod;..."); // gerçek prod DB!
var servis = new EmailServis("smtp.gmail.com"); // gerçek email!
}
// ✅ Her zaman mock kullan
- Bir testte çok fazla
Assert
// ❌ Kötü — ilk Assert başarısız olunca diğerleri hiç çalışmaz
Assert.Equal("Laptop", urun.Ad);
Assert.Equal(25000m, urun.Fiyat);
Assert.Equal(10, urun.Stok);
Assert.True(urun.AktifMi);
Assert.NotNull(urun.Kategori);
// ✅ FluentAssertions ile daha temiz
urun.Should().NotBeNull();
urun.Ad.Should().Be("Laptop");
urun.Fiyat.Should().Be(25000m);
Testleri Çalıştırma
# Tüm testleri çalıştır
dotnet test
# Belirli bir proje
dotnet test ./MyProject.Tests/
# Trait filtresi ile
dotnet test --filter "Katman=Servis"
# Test adı filtresi
dotnet test --filter "FullyQualifiedName~SiparisServisTests"
# Ayrıntılı çıktı
dotnet test --logger "console;verbosity=detailed"
Özet Tablosu
| Kavram | Ne İşe Yarar | Kullanım |
|---------------------------|------------------------------------------|------------------------
| [Fact] | Tek, sabit bir senaryoyu test eder | En temel test türü
| [Theory] + [InlineData] | Aynı testi farklı verilerle çalıştırır | Çoklu giriş/çıkış
| [MemberData] | Test verisini static property'den alır | Nesne içeren veri
| [ClassData] | Test verisini ayrı sınıftan alır | Büyük ve paylaşılan veri
| Assert.Equal | Değer eşitliği kontrol eder | Temel doğrulama
| Assert.Throws | Exception fırlatıldığını doğrular | Hata senaryoları
| IDisposable | Her test öncesi/sonrası temizlik | Kaynak yönetimi
| IClassFixture<T> | Pahalı nesneyi sınıf içi paylaştırır | DB, HTTP client
| [Trait] | Testleri etiketler ve filtreler | Gruplama
| Mock<T> | Sahte nesne oluşturur | Bağımlılık izolasyonu
| Setup | Mock davranışı tanımlar | "Şu gelirse bunu yap"
| It.IsAny<T>() | Herhangi bir değeri kabul eder | Esnek eşleştirme
| It.Is<T>(koşul) | Koşula uyan değeri kabul eder | Spesifik eşleştirme
| Returns | Dönüş değeri belirler | Sync metotlar
| ReturnsAsync | Async dönüş değeri belirler | Async metotlar
| Throws / ThrowsAsync | Hata fırlatmasını simüle eder | Exception senaryoları
| Callback | Metot çağrıldığında yan etki üretir | Kayıt, takip
| Verify | Metodun çağrılıp çağrılmadığını doğrular | "Bu çağrıldı mı?"
| Times.Once | Tam 1 kez çağrıldığını doğrular | Sık kullanılan
| Times.Never | Hiç çağrılmadığını doğrular | Hata senaryoları
| FluentAssertions.Should() | Okunabilir assertion'lar | Tüm doğrulamalar
Sonuç
Test yazmak başta ekstra iş gibi görünür. Ama birkaç ay sonra projeye döndüğünüzde, testlerin varlığı size inanılmaz bir özgüven verir. “Bu değişikliği yaparsam ne bozulur?” sorusunun cevabını artık dotnet test verecektir.
메타데이터
- post_id
- 86c8fd1f0403
- slug
- nette-test-yazmanın-tam-rehberi-xunit-moq-ile-sıfırdan-profesyonel-testler-86c8fd1f0403
- url
- https://medium.com/@ongguzel/nette-test-yazman%C4%B1n-tam-rehberi-xunit-moq-ile-s%C4%B1f%C4%B1rdan-profesyonel-testler-86c8fd1f0403
- canonical_url
- https://medium.com/@ongguzel/nette-test-yazman%C4%B1n-tam-rehberi-xunit-moq-ile-s%C4%B1f%C4%B1rdan-profesyonel-testler-86c8fd1f0403
- author_url
- https://medium.com/@ongguzel
- status
- ok
- fetched_at
- 2026-07-11 01:01:15