ASP.NET Core Web API'de Unit Test: xUnit ve Moq ile Service ve Controller Testleri
xUnit ve Moq ile ASP.NET Core Web API'de unit test: net6.0 için doğru paket sürümleri, AAA ve isimlendirme, Setup ve Verify, ActionResult<T> testlerindeki Result/Value farkı, Theory ve NSubstitute karşılaştırması.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Bir önceki yazıda health check'lerle çalışan uygulamanın nabzını ölçtük. Ama nabız, kodun doğru çalıştığını söylemez; sadece çalıştığını söyler. Fiyat hesaplamasındaki bir yuvarlama hatası health check'i hiç rahatsız etmez. O hatayı deploy'dan önce yakalamanın en ucuz yolu unit test.
Önce bir tahmin. Şu action'ı yıllardır yazıyoruz:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return NotFound();
}
return product;
}
Ürün bulunduğunda HTTP tarafında 200 OK döndüğünü biliyoruz. Bunu test ederken Assert.IsType<OkObjectResult>(result.Result) yazarsan test geçer mi? Cevap controller testleri bölümünde.
Unit Test Neyi Test Eder?
Unit test, kodunun küçük bir parçasını, bir method'u ya da bir class'ı, dünyanın geri kalanından yalıtarak çalıştırır ve sonucunu kontrol eder. Veritabanı yok, HTTP yok, dosya sistemi yok. Bu yüzden milisaniyeler içinde biter ve her seferinde aynı sonucu verir. Bir arabanın frenini test etmek için onu otobana çıkarmazsın; fren sistemini test tezgâhına bağlar, pedala basar ve basıncı ölçersin. Unit test o tezgâh.
Neyi test etmediğini bilmek de önemli. ASP.NET Core'un routing'ini, model binding'ini ya da EF Core'un SQL üretimini unit test'le doğrulamaya çalışma; onlar framework'ün işi ve onları bir arada görmek bir sonraki yazının, yani integration test'in konusu. Unit test'in alanı senin kararların: iş kuralları, hesaplamalar, "şu durumda şu exception fırlasın" mantığı, controller'ın hangi durumda hangi sonucu döndüğü.
Test edeceğimiz kod, katmanlı yapı yazısında kurduğumuz düzenin sade bir hâli: bir repository interface'i, onu kullanan bir ProductService ve service'i kullanan bir controller.
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Services;
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken);
Task AddAsync(Product product, CancellationToken cancellationToken);
}
public interface IProductService
{
Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken);
}
public class DuplicateProductException : Exception
{
public DuplicateProductException(string name)
: base($"A product named '{name}' already exists.")
{
}
}
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var product = await _repository.GetByIdAsync(id, cancellationToken);
return product is null ? null : new ProductDto(product.Id, product.Name, product.Price);
}
public async Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken)
{
var name = request.Name.Trim();
if (await _repository.ExistsByNameAsync(name, cancellationToken))
{
throw new DuplicateProductException(name);
}
var product = new Product { Name = name, Price = request.Price };
await _repository.AddAsync(product, cancellationToken);
return new ProductDto(product.Id, product.Name, product.Price);
}
}
ProductDto ve CreateProductRequest birer record: record ProductDto(int Id, string Name, decimal Price) ve record CreateProductRequest(string Name, decimal Price). Controller da yukarıdaki GetById ile, CreatedAtAction dönen bir Create action'ından oluşuyor.
Test Projesini Kurmak
Testler ayrı bir projede durur. Klasör düzeni src/ ve tests/ olsun:
dotnet new sln -n ProductCatalog
dotnet new xunit -o tests/ProductCatalog.Api.Tests
dotnet sln add src/ProductCatalog.Api tests/ProductCatalog.Api.Tests
dotnet add tests/ProductCatalog.Api.Tests reference src/ProductCatalog.Api
Şablonun ürettiği csproj'yi şu hâle getir:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- net6.0 ve net10.0'da çalıştığını doğruladığımız sürümler -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="NSubstitute" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ProductCatalog.Api\ProductCatalog.Api.csproj" />
</ItemGroup>
</Project>
Sürümler tesadüf değil. NuGet'teki paket içeriklerine baktığımızda Microsoft.NET.Test.Sdk 17.14 ile, xunit.runner.visualstudio da 3.1 ile net8.0 altını bırakıyor; csproj'deki 17.13.0 ve 3.0.2, net6.0'ı hâlâ destekleyen son sürümler. Moq 4.21.0 ve xUnit 2.9.3 net6.0'ı doğrudan hedefliyor, NSubstitute 6.2.0 ise netstandard2.0 üzerinden çalışıyor. Aynı csproj'yi net10.0 ile de derleyip çalıştırdık, hepsi orada da sorunsuz.
Bunu neden bu kadar dert ettiğimizi göstereyim. .NET 10 SDK'sı ile dotnet new xunit dediğinde şablon Microsoft.NET.Test.Sdk 17.14.1 ve xunit.runner.visualstudio 3.1.4 koyuyor. TargetFramework'ü net6.0'a çekip dotnet test dersen build şöyle duruyor:
error : Microsoft.NET.Test.Sdk doesn't support net6.0 and has not been tested with it. Consider upgrading your TargetFramework to net8.0 or later. You may also set <SuppressTfmSupportBuildErrors>true</SuppressTfmSupportBuildErrors> in the project file to ignore this warning and attempt to run in this unsupported configuration at your own risk.
İlk Test: AAA ve İsimlendirme
using Moq;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;
namespace ProductCatalog.Api.Tests;
public class ProductServiceTests
{
private readonly Mock<IProductRepository> _repository = new();
private readonly ProductService _sut;
public ProductServiceTests()
{
_sut = new ProductService(_repository.Object);
}
[Fact]
public async Task GetByIdAsync_ProductExists_ReturnsDto()
{
// Arrange: hazırlık
_repository
.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m });
// Act: test edilen tek çağrı
var result = await _sut.GetByIdAsync(1, CancellationToken.None);
// Assert: beklenen sonuç
Assert.NotNull(result);
Assert.Equal("Mechanical Keyboard", result.Name);
Assert.Equal(1450m, result.Price);
}
[Fact]
public async Task GetByIdAsync_ProductMissing_ReturnsNull()
{
var result = await _sut.GetByIdAsync(42, CancellationToken.None);
Assert.Null(result);
}
}
Her test üç bölümden oluşuyor: Arrange ortamı kurar, Act test edilen davranışı tek bir çağrıyla tetikler, Assert sonucu kontrol eder. Bu düzen, test okuyan birinin "neyi hazırladık, neyi çağırdık, ne bekliyoruz" sorularına göz gezdirerek cevap bulmasını sağlıyor. Act bölümünde birden fazla satır görüyorsan, muhtemelen iki şeyi birden test ediyorsun.
İsimlendirmede Method_Scenario_ExpectedResult kalıbını, yani method, durum ve beklenen sonuç sırasını kullanıyoruz. GetByIdAsync_ProductMissing_ReturnsNull adını okuyan biri, test kırmızıya döndüğünde kodu açmadan neyin bozulduğunu anlar. _sut da "system under test" demek; test edilen nesneyi diğer her şeyden ayırmak için yaygın bir kısaltma.
xUnit her test için test class'ından yeni bir instance oluşturur. Constructor'daki kurulum her testte baştan çalışır, mock'lar testler arasında paylaşılmaz. Ayrı bir [SetUp] method'una ihtiyaç yok.
Moq ile Bağımlılıkları Taklit Etmek
ProductService bir IProductRepository istiyor. Gerçeğini verirsek test veritabanına bağımlı olur. Bunun yerine Moq ile sahte bir nesne üretiyoruz: new Mock<IProductRepository>() interface'i uygulayan bir class'ı çalışma anında oluşturur, .Object da o nesnenin kendisi.
Setup ile "bu method şu argümanlarla çağrılırsa şunu dön" diyorsun. It.IsAny<CancellationToken>() hangi token gelirse gelsin eşleşir. İkinci teste dikkat: hiç Setup yapmadık ve test geçti. Moq'un varsayılan modu "loose": kurulmamış bir method çağrılırsa exception fırlatmaz, dönüş tipinin varsayılan değerini döner. Task<Product?> için bu, sonucu null olan tamamlanmış bir Task. Kullanışlı, ama bir testin yanlış sebeple geçmesine de yol açabilir; birazdan döneceğiz.
Bazen dönüş değeri değil, bir yan etki önemlidir. Aynı isimde ürün varsa service hiçbir şey kaydetmemeli:
[Fact]
public async Task CreateAsync_NameAlreadyExists_ThrowsAndDoesNotSave()
{
_repository
.Setup(r => r.ExistsByNameAsync("Wireless Mouse", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var request = new CreateProductRequest(" Wireless Mouse ", 650m);
await Assert.ThrowsAsync<DuplicateProductException>(
() => _sut.CreateAsync(request, CancellationToken.None));
_repository.Verify(
r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task CreateAsync_ValidRequest_SavesTrimmedName()
{
var request = new CreateProductRequest(" USB-C Hub ", 899m);
var result = await _sut.CreateAsync(request, CancellationToken.None);
Assert.Equal("USB-C Hub", result.Name);
_repository.Verify(
r => r.AddAsync(
It.Is<Product>(p => p.Name == "USB-C Hub" && p.Price == 899m),
It.IsAny<CancellationToken>()),
Times.Once);
}
İlk testte istek " Wireless Mouse " ile geliyor, ama setup "Wireless Mouse" için yapıldı. Test geçiyorsa service ismi kontrol etmeden önce trim'liyor demektir; yani test bir iş kuralını da belgeliyor. Assert.ThrowsAsync exception'ı bekliyor, Verify ile Times.Never da AddAsync'in hiç çağrılmadığını doğruluyor. İkinci testte It.Is, kaydedilen nesnenin içine bakıyor: trim'lenmiş isim ve doğru fiyat.
Controller Testleri ve ActionResult<T>
Controller'ı test ederken service'i mock'luyoruz. Controller sıradan bir class; new ile oluşturup action'ı doğrudan çağırabilirsin.
using Microsoft.AspNetCore.Mvc;
using Moq;
using ProductCatalog.Api.Controllers;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;
namespace ProductCatalog.Api.Tests;
public class ProductsControllerTests
{
private readonly Mock<IProductService> _service = new();
private readonly ProductsController _controller;
public ProductsControllerTests()
{
_controller = new ProductsController(_service.Object);
}
[Fact]
public async Task GetById_ProductExists_ReturnsProductAsValue()
{
var dto = new ProductDto(1, "Mechanical Keyboard", 1450m);
_service
.Setup(s => s.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(dto);
var result = await _controller.GetById(1, CancellationToken.None);
// return product; satırı Result'ı değil Value'yu doldurur
Assert.Null(result.Result);
Assert.Equal(dto, result.Value);
}
[Fact]
public async Task GetById_ProductMissing_ReturnsNotFound()
{
var result = await _controller.GetById(42, CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task Create_ValidRequest_ReturnsCreatedAtGetById()
{
var request = new CreateProductRequest("USB-C Hub", 899m);
_service
.Setup(s => s.CreateAsync(request, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProductDto(3, "USB-C Hub", 899m));
var result = await _controller.Create(request, CancellationToken.None);
var created = Assert.IsType<CreatedAtActionResult>(result.Result);
Assert.Equal(nameof(ProductsController.GetById), created.ActionName);
Assert.Equal(3, created.RouteValues!["id"]);
Assert.Equal(new ProductDto(3, "USB-C Hub", 899m), created.Value);
}
}
Tahminin cevabı ilk testte saklı. O testteki iki Assert satırının yerine Assert.IsType<OkObjectResult>(result.Result) yazdığımızda test şöyle kırmızıya döndü:
Failed ProductCatalog.Api.Tests.ProductsControllerTests.GetById_ProductExists_ReturnsProductAsValue [26 ms]
Error Message:
Assert.IsType() Failure: Value is null
Expected: typeof(Microsoft.AspNetCore.Mvc.OkObjectResult)
Actual: null
ActionResult<T>'nin iki yuvası var: Result ve Value. return NotFound(); dediğinde implicit dönüşüm Result'ı doldurur. return product; dediğinde ise Value dolar, Result null kalır. 200 OK'yi, action bittikten sonra MVC'nin result executor'ı Value'dan üretir; senin unit test'in o aşamaya hiç gelmez. Testin istediği OkObjectResult'sa action'da return Ok(product); yazman gerekir. İkisi de doğru, ama test hangisini yazdığını bilmeli.
Üçüncü test, önceki yazılarda sık kullandığımız CreatedAtAction'ı kontrol ediyor. Status code'a değil, kararın kendisine bakıyoruz: hangi action'a işaret ediyor, route değeri doğru mu, gövde doğru mu. Location header'ının gerçek URL'sini üretmek routing'in işi; onu integration test'te göreceğiz.
Theory ile Aynı Testi Farklı Verilerle
Saf fonksiyonlar unit test'in en rahat müşterisidir. İndirim hesaplayan küçük bir yardımcı:
namespace ProductCatalog.Api.Services;
public static class PriceCalculator
{
public static decimal ApplyDiscount(decimal price, int discountPercent)
{
if (discountPercent < 0 || discountPercent > 90)
{
throw new ArgumentOutOfRangeException(nameof(discountPercent));
}
return Math.Round(price * (100 - discountPercent) / 100, 2);
}
}
using ProductCatalog.Api.Services;
using Xunit;
namespace ProductCatalog.Api.Tests;
public class PriceCalculatorTests
{
[Theory]
[InlineData(1450, 10, 1305)]
[InlineData(650, 0, 650)]
[InlineData(99.99, 15, 84.99)]
public void ApplyDiscount_ValidPercent_ReturnsRoundedPrice(decimal price, int percent, decimal expected)
{
var result = PriceCalculator.ApplyDiscount(price, percent);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(-1)]
[InlineData(91)]
public void ApplyDiscount_PercentOutOfRange_Throws(int percent)
{
Assert.Throws<ArgumentOutOfRangeException>(() => PriceCalculator.ApplyDiscount(100m, percent));
}
}
[Fact] tek bir durumu, [Theory] ise bir kuralı test eder; her [InlineData] ayrı bir test olarak çalışır ve raporda ayrı görünür. Sınır değerlerini (0, 90, 91, -1) mutlaka ekle; hatalar en çok orada saklanır.
Bir detay: [InlineData(1450m, 10, 1305m)] yazmayı denersen derleyici durdurur:
PriceCalculatorTests.cs(9,17): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type
C# attribute argümanı olarak decimal kabul etmiyor. Sayıları int ya da double olarak yazıyorsun, xUnit onları parametrenin tipi olan decimal'a çeviriyor. Hassasiyet senin için kritikse [MemberData] ile değerleri bir property'den decimal olarak ver.
Bütün testleri çalıştıralım:
dotnet test
Passed! - Failed: 0, Passed: 13, Skipped: 0, Total: 13, Duration: 40 ms - ProductCatalog.Api.Tests.dll (net6.0)
On üç test, 40 milisaniye. Aynı testler net10.0'da da geçti.
NSubstitute ile Aynı Test
Moq tek seçenek değil. NSubstitute daha az tören isteyen bir syntax sunuyor; aynı test şöyle:
using NSubstitute;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;
namespace ProductCatalog.Api.Tests;
public class ProductServiceNSubstituteTests
{
[Fact]
public async Task CreateAsync_NameAlreadyExists_ThrowsAndDoesNotSave()
{
var repository = Substitute.For<IProductRepository>();
repository.ExistsByNameAsync("Wireless Mouse", Arg.Any<CancellationToken>()).Returns(true);
var sut = new ProductService(repository);
await Assert.ThrowsAsync<DuplicateProductException>(
() => sut.CreateAsync(new CreateProductRequest("Wireless Mouse", 650m), CancellationToken.None));
await repository.DidNotReceive().AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
}
}
.Object yok, lambda içinde setup yok; sahte nesneyi doğrudan çağırıp .Returns ekliyorsun. Hangisini seçtiğinin pek önemi yok, önemli olan projede tek birinde karar kılmak. Moq tarafında bir tarih notu: Ağustos 2023'te 4.20.0 sürümü, build sırasında geliştiricinin git e-posta bilgisini okuyan SponsorLink adlı bir bileşenle çıktı ve tepki üzerine geri çekildi. NuGet'te 4.20.0 ve 4.20.1 bugün listeden kaldırılmış ve güvenlik uyarısı taşıyor; sürüm sabitlerken bunlardan uzak dur.
Sık Düşülen Tuzaklar
Her şeyi Verify etmek. Bir testte service'in her çağrısını doğrularsan, davranışı değil uygulamayı test etmiş olursun; kodu yeniden düzenlediğin anda, davranış aynı kalsa bile testler kırılır. Verify'ı yan etkinin kendisi önemli olduğunda kullan, örneğimizdeki "kaydetmemeli" gibi.
Loose mock'un yalanı. Setup'ı yanlış argümanla yaptıysan, örneğin GetByIdAsync(1, ...) yerine GetByIdAsync(2, ...), Moq gerçek çağrıya null döner ve "ürün yoksa null döner" testin yanlış sebeple yeşil kalır. Şüphelendiğin yerde new Mock<IProductRepository>(MockBehavior.Strict) kullan; kurulmamış her çağrı exception fırlatır.
DbContext'i mock'lamak. DbSet'in LINQ davranışını taklit etmek hem zahmetli hem yanıltıcı; mock'lanmış bir DbSet SQL'e çevrilemeyen bir sorguyu memnuniyetle çalıştırır. Repository arkasındaki EF kodunu gerçek bir veritabanıyla, integration test'te sına.
async void test yazmak. xUnit 2 bunu hâlâ çalıştırıyor, ama build sırasında analyzer uyarıyor: xUnit1048: Support for 'async void' unit tests is being removed from xUnit.net v3. Her zaman async Task dön. Aynı şekilde testin içinde .Result ya da .Wait() kullanma; async/await yazısında konuştuğumuz deadlock ve exception sarmalama dertleri testte de geçerli.
Son olarak, Moq yalnızca interface'leri ve virtual üyeleri taklit edebilir. Somut bir class'ın virtual olmayan bir method'unu setup etmeye çalışırsan çalışma anında exception alırsın. Bu da bağımlılıkları interface üzerinden almanın, DI yazısında konuştuğumuz test edilebilirlik kazancının ta kendisi.
Küçük Bir Deneme
★☆☆ GetById action'ındaki return product; satırını return Ok(product); yap. Hangi testin kırıldığını gör, testi yeni davranışa göre Assert.IsType<OkObjectResult> ile yeniden yaz.
★★☆ ProductServiceTests'teki mock'u MockBehavior.Strict ile oluştur. Hangi testlerin kırıldığına bak, her biri için eksik Setup'ı ekle ve hangi testin daha önce yanlış sebeple geçtiğini bul.
★★★ PriceCalculator'a "indirimli fiyat 1 TL'nin altına düşemez" kuralını ekle. Önce kuralı anlatan [Theory]'yi yaz ve kırmızı olduğunu gör, sonra kodu yazıp yeşile çevir. [MemberData] ile decimal değerler veren bir veri kaynağı kullan.
Unit testlerimiz parçaları tek tek doğruluyor, ama controller'ın gerçekten /api/products/1 adresine bağlı olduğunu, JSON'un doğru serialize edildiğini ya da validation'ın 400 döndüğünü hiçbiri görmüyor. Bir sonraki yazıda WebApplicationFactory ile bütün uygulamayı bellekte ayağa kaldırıp HTTP seviyesinde integration test yazacağız.