Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #30

ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test

Mert Özen 4 Kas 2026 9 dk 1 görüntülenme
ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test

WebApplicationFactory ile ASP.NET Core Web API'yi bellekte ayağa kaldırıp HTTP seviyesinde test ediyoruz: public partial class Program, SQLite in-memory ile veritabanı değiştirmek, Location ve ProblemDetails testleri, test izolasyonu.

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 service'leri ve controller'ları tek tek, mock'larla test ettik. O testlerin hiçbiri bir HTTP isteği görmedi. Bu yazıda bütün uygulamayı bellekte ayağa kaldırıp ona gerçek HTTP istekleri atacağız. Aracımız Microsoft.AspNetCore.Mvc.Testing paketindeki WebApplicationFactory<TEntryPoint>.

Tahmin et: .NET 6 ile oluşturulmuş, top-level statement'lı bir Program.cs'in var. Test projesinde WebApplicationFactory<Program> yazıyorsun. Proje derlenir mi? Aynı kod .NET 10'da derlenir mi? İki sorunun cevabı farklı.

Unit Test'in Göremedikleri

Unit test'te controller'ı new ile oluşturup action'ı doğrudan çağırdık. Bu arada atladığımız her şeyi düşün: route template'i /api/products/{id:int}'i gerçekten eşliyor mu, JSON body CreateProductRequest'e bağlanıyor mu, [ApiController] geçersiz modelde gerçekten 400 dönüyor mu, response camelCase serialize ediliyor mu, DI kayıtları eksiksiz mi. Bunların hiçbiri senin kodunda değil, hepsi pipeline'da. Ve hataların önemli bir kısmı tam da bu dikişlerde çıkıyor.

WebApplicationFactory, Program.cs'ini olduğu gibi çalıştırır, ama Kestrel yerine bellekte çalışan bir TestServer kullanır. Ağ yok, port yok; CreateClient() ile aldığın HttpClient istekleri doğrudan pipeline'a verir. Middleware'ler, routing, model binding, filter'lar, serialization hepsi gerçek. Tiyatro benzetmesiyle: unit test oyuncuyla tek başına yapılan prova, integration test ise kostümlü, ışıklı, dekorlu genel prova. Seyirci yok, ama sahnede her şey gerçek.

Test edeceğimiz API, EF Core yazısındaki düzenin sade hâli: SQLite kullanan bir AppDbContext ve onu doğrudan kullanan bir ProductsController.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Api.Data;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;

    public ProductsController(AppDbContext db)
    {
        _db = db;
    }

    [HttpGet]
    public async Task<List<ProductDto>> GetAll(CancellationToken cancellationToken)
    {
        return await _db.Products
            .AsNoTracking()
            .OrderBy(p => p.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .ToListAsync(cancellationToken);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
    {
        var product = await _db.Products.FindAsync(new object[] { id }, cancellationToken);
        if (product is null)
        {
            return NotFound();
        }

        return new ProductDto(product.Id, product.Name, product.Price);
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken cancellationToken)
    {
        var product = new Product { Name = request.Name, Price = request.Price };
        _db.Products.Add(product);
        await _db.SaveChangesAsync(cancellationToken);

        var dto = new ProductDto(product.Id, product.Name, product.Price);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, dto);
    }
}

public record ProductDto(int Id, string Name, decimal Price);

public class CreateProductRequest
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1_000_000)]
    public decimal Price { get; set; }
}

Program'ı Teste Açmak

Factory'nin generic parametresi, uygulamanın giriş noktasını bulmak için kullanılıyor; Program.cs'teki Program class'ı tam olarak bu. Ama top-level statement'lardan derleyicinin ürettiği Program class'ı internal. Test projesi başka bir assembly olduğu için ona erişemiyor. net6.0'da build şöyle duruyor:

CatalogApiFactory.cs(11,56): error CS0122: 'Program' is inaccessible due to its protection level

Çözüm Program.cs'in en altına tek bir bildirim eklemek:

using Microsoft.EntityFrameworkCore;
using ProductCatalog.Api.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

app.MapControllers();

app.Run();

// Test projesinin WebApplicationFactory<Program> yazabilmesi için
public partial class Program
{
}

partial olduğu için derleyicinin ürettiği class'la birleşiyor ve onu public yapıyor. Tahminin ikinci yarısı: aynı satırı silip net10.0'da derlediğimizde hata çıkmadı, çünkü .NET 10'da bu bildirimi bir source generator kendisi üretiyor. Satırı bırakmanın .NET 10'da bir zararı yok; iki hedefte de derleniyor. Alternatif olarak API projesine InternalsVisibleTo ile test assembly'sine erişim de verebilirsin, ama tek satırlık partial class daha az sürpriz içeriyor.

Test Projesini Kurmak

Unit test projesindeki paketlere bir tane ekleniyor. Microsoft.AspNetCore.Mvc.Testing'in sürümü ASP.NET Core'un sürümüyle aynı gitmeli: net6.0 için 6.0.36, net10.0 için 10.0.14.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <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="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.36" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ProductCatalog.Api\ProductCatalog.Api.csproj" />
  </ItemGroup>

</Project>

EF Core ve SQLite paketleri API projesinden transitive olarak geliyor, test projesine ayrıca eklemene gerek yok. Mvc.Testing paketi build sırasında API'nin .deps.json dosyasını test klasörüne kopyalıyor; factory uygulamanın bağımlılıklarını ve content root'unu bu sayede buluyor.

İlk Deneme: Gerçek Veritabanına Çarpmak

En sade hâliyle bir test şöyle başlar:

public class PlainFactoryTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public PlainFactoryTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetAll_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/products");

        response.EnsureSuccessStatusCode();
    }
}

Bu test kırmızı. Response 500 döndü ve gövdede SQLite Error 1: 'no such table: Products' yazıyordu. Üstelik test projesinin bin klasöründe boş bir catalog.db dosyası belirdi. Olan şu: factory Program.cs'i olduğu gibi çalıştırdı, Program.cs de appsettings.json'daki Data Source=catalog.db'yi okudu. Integration test'in gücü de zaafı da bu: her şey gerçek, veritabanı dahil. Testlerin geliştirme veritabanına yazması ya da ondan okuması ise hiç istemediğin bir şey.

Veritabanını Değiştirmek

Kendi factory'mizi yazıp DI kaydını test için değiştiriyoruz:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProductCatalog.Api.Data;

namespace ProductCatalog.Api.IntegrationTests;

public class CatalogApiFactory : WebApplicationFactory<Program>
{
    // Bağlantı açık kaldıkça bellekteki veritabanı yaşar
    private readonly SqliteConnection _connection = new("Data Source=:memory:");

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

        builder.ConfigureServices(services =>
        {
            // Program.cs'teki kaydı bul ve çıkar
            var descriptor = services.Single(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            services.Remove(descriptor);

            _connection.Open();
            services.AddDbContext<AppDbContext>(options => options.UseSqlite(_connection));
        });
    }

    protected override IHost CreateHost(IHostBuilder builder)
    {
        var host = base.CreateHost(builder);

        // Şemayı oluştur ve başlangıç verisini yükle
        using var scope = host.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Database.EnsureCreated();
        db.Products.AddRange(
            new Product { Name = "Mechanical Keyboard", Price = 1450m },
            new Product { Name = "Wireless Mouse", Price = 650m });
        db.SaveChanges();

        return host;
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        _connection.Dispose();
    }
}

Üç override, üç iş. ConfigureWebHost, Program.cs'teki kayıtlardan sonra çalışır; burada AddDbContext'in eklediği DbContextOptions<AppDbContext> kaydını bulup çıkarıyor ve yerine bellekteki SQLite'ı koyuyoruz. UseEnvironment("Testing") ile de uygulamaya hangi ortamda çalıştığını söylüyoruz; factory'nin varsayılanı Development.

SQLite'ın :memory: veritabanı bağlantıya bağlıdır: bağlantı kapanınca veritabanı da kaybolur. Bu yüzden tek bir bağlantıyı açık tutup her DbContext'e onu veriyoruz. CreateHost, host oluştuktan hemen sonra şemayı EnsureCreated ile kurup iki ürün ekliyor. Dispose da bağlantıyı factory ile birlikte kapatıyor. Neden EF Core'un InMemory provider'ı değil? Çünkü SQLite gerçek bir ilişkisel veritabanı: SQL'e çevrilemeyen bir sorgu ya da bir unique index ihlali burada da hata verir. InMemory provider ise SQL üretmediği için bu tür hataları sessizce geçer.

HTTP Seviyesinde Testler

using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Controllers;
using Xunit;

namespace ProductCatalog.Api.IntegrationTests;

public class ProductsApiTests : IClassFixture<CatalogApiFactory>
{
    private readonly HttpClient _client;

    public ProductsApiTests(CatalogApiFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetAll_ReturnsSeededProducts()
    {
        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        Assert.NotNull(products);
        Assert.Contains(products, p => p.Name == "Mechanical Keyboard");
    }

    [Fact]
    public async Task GetById_UnknownId_ReturnsProblemDetails()
    {
        var response = await _client.GetAsync("/api/products/999");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    }

    [Fact]
    public async Task Create_ValidProduct_ReturnsCreatedWithLocation()
    {
        var response = await _client.PostAsJsonAsync("/api/products", new { name = "USB-C Hub", price = 899 });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);

        // Location header'ındaki adres gerçekten çalışıyor mu?
        var created = await _client.GetFromJsonAsync<ProductDto>(response.Headers.Location);
        Assert.Equal("USB-C Hub", created?.Name);
    }

    [Fact]
    public async Task Create_ZeroPrice_ReturnsValidationProblem()
    {
        var response = await _client.PostAsJsonAsync("/api/products", new { name = "Free Sticker", price = 0 });

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
        Assert.NotNull(problem);
        Assert.True(problem.Errors.ContainsKey("Price"));
    }
}

Bu testlerin her biri unit test'in göremediği bir şeyi doğruluyor. İkincisi, NotFound()'un [ApiController] sayesinde application/problem+json türünde bir ProblemDetails'e dönüştüğünü. Üçüncüsü, unit test'te sadece ActionName'ine baktığımız CreatedAtAction'ın ürettiği gerçek adresi; bizim çalıştırmamızda Location değeri http://localhost/api/Products/3 oldu ve o adrese atılan GET yeni ürünü döndü. Dördüncüsü, validation attribute'larının, model binding'in ve otomatik 400'ün birlikte çalıştığını; gövdedeki errors sözlüğünde Price anahtarı var.

Body'yi bilerek anonim bir object ile ve camelCase property'lerle gönderiyoruz. İstemci gerçekte böyle JSON gönderir; CreateProductRequest'i doğrudan kullansaydık serialization uyumsuzluklarını gözden kaçırabilirdik.

dotnet test
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 476 ms - ProductCatalog.Api.IntegrationTests.dll (net6.0)

Dört test yarım saniyede bitti. Unit testlerin 40 milisaniyesine göre yavaş, ama gerçek bir sunucu ve veritabanıyla yapılan testlere göre çok hızlı. Aynı testler net10.0'da da geçti.

Testler Birbirini Etkilemesin

IClassFixture<CatalogApiFactory>, factory'nin o class'taki bütün testler için bir kez oluşturulmasını sağlıyor. Uygulamayı her test için baştan ayağa kaldırmamak büyük bir hız kazancı, ama bir bedeli var: veritabanı da paylaşılıyor. Create_ValidProduct testi bir ürün eklediğinde, o ürün aynı class'taki sonraki testlerde de orada.

Bu yüzden GetAll testinde "tam iki ürün var" demedik, "Mechanical Keyboard listede" dedik. Test sırası değişirse ya da yeni bir test başka bir ürün eklerse ilk yazım kırılırdı, ikincisi kırılmaz. Aynı sebeple üçüncü testte yeni ürünün id'sinin 3 olduğunu da assert etmedik; o 3 sadece bu çalıştırmada testlerin sırasından çıkan bir sonuçtu.

Daha sıkı bir izolasyon gerekiyorsa seçeneklerin var: her test class'ı zaten kendi factory'sini, dolayısıyla kendi veritabanını alır; tek bir testin temiz başlaması gerekiyorsa o testi ayrı bir class'a koyabilir ya da testten önce tabloları temizleyebilirsin. Testcontainers ile her çalıştırmada gerçek bir SQL Server veya PostgreSQL container'ı açmak da yaygın; onu EF Core serisinde detaylandırıyoruz.

Sık Düşülen Tuzaklar

Provider değiştirirken sürüm farkı. Bu yazıdaki "descriptor'ı bul, çıkar, yenisini ekle" yöntemi, aynı provider'da kaldığımız için iki hedefte de çalıştı. Ama SQLite yerine EF Core'un InMemory provider'ına geçmeyi denediğimizde net6.0'da testler geçti, net10.0'da dördü de şu hatayla düştü:

System.InvalidOperationException : Services for database providers 'Microsoft.EntityFrameworkCore.Sqlite', 'Microsoft.EntityFrameworkCore.InMemory' have been registered in the service provider. Only a single database provider can be registered in a service provider.

Yeni EF Core sürümlerinde AddDbContext, options'ı tek bir kayıtta tutmuyor; Program.cs'teki UseSqlite çağrısı başka bir kayıt üzerinden de yaşamaya devam ediyor. Bu farkı ve yeni sürümlerdeki çözümü sürüm yazılarında göreceğiz.

Mesaj metnine assert etmek. Validation hatasının metni bu makinede The field Price must be between 0,01 and 1000000. idi; ondalık ayracı virgül, çünkü testler Türkçe kültürlü bir makinede çalıştı. Aynı test, invariant kültürlü bir CI sunucusunda 0.01 görür. Hata mesajının metnine değil, status code'a ve errors anahtarına assert et.

Ortamı unutmak. Factory'nin varsayılan ortamı Development, yani developer exception page açık ve Development'a özel her şey çalışıyor; ilk denemedeki 500'ün gövdesinde stack trace görmemiz bu yüzdendi. Production davranışını test etmek istiyorsan UseEnvironment ile ortamı açıkça belirle ve Program.cs'teki IsDevelopment() bloklarının teste etkisini hesaba kat.

EnsureCreated'ı migration'la karıştırmak. Testte şemayı modelden doğrudan kuruyoruz; bu hızlı, ama migration'larının doğru olup olmadığını test etmez. Migration'ları ayrı bir adımda, gerçek bir veritabanına uygulayarak doğrula.

Küçük Bir Deneme

★☆☆ GetById_UnknownId testine gövdeyi ProblemDetails olarak okuyan ve Status değerinin 404 olduğunu kontrol eden bir satır ekle.

★★☆ CreateProductRequest.Name'e 101 karakterlik bir isim gönderen bir test yaz ve errors'ta Name anahtarını bekle. Sonra [StringLength(100)]'ı silip testin kırıldığını gör; unit testlerin bunu neden yakalayamayacağını düşün.

★★★ İkinci bir test class'ı yaz ve GetAll'un tam iki ürün döndüğünü assert et; kendi factory'sini aldığı için Create testinden etkilenmediğini gör. Sonra ICollectionFixture ile iki class'ın aynı factory'yi paylaşmasını sağla ve bu assert'in neden artık sıraya bağlı hâle geldiğini incele.

Testlerimiz artık hem parçaları hem bütünü doğruluyor. Sırada bu doğrulanmış uygulamayı paketleyip her ortamda aynı şekilde çalıştırmak ve her push'ta testleri otomatik çalıştırmak var. Bir sonraki yazıda multi-stage Dockerfile ile uygulamayı container'a alıp GitHub Actions ile CI/CD kuruyoruz.