Mert Özen Her Satırda Daha İleri
Test ve Kalite Integration ve E2E Test — #01

.NET 10'da Integration Test Altyapısı: Neyi Gerçek, Neyi Sahte Çalıştırıyoruz?

Mert Özen 14 Kas 2026 15 dk 1 görüntülenme
.NET 10'da Integration Test Altyapısı: Neyi Gerçek, Neyi Sahte Çalıştırıyoruz?

Bu serideki integration test, API'yi test process'inde production pipeline'ıyla, veritabanını gerçek SQL Server container'ıyla çalıştırır; yalnız ödeme, kargo ve e-posta gibi dış servisler sınırda taklit edilir. SQLite ve InMemory'nin collation, uzunluk, precision ve transaction farklarını gerçek koşuyla gösteriyor, xUnit v3 + Testcontainers iskeletini kurup unit testlerden ayırıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3, Microsoft.AspNetCore.Mvc.Testing 10.0.15, Testcontainers for .NET 4.16 ve SQL Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore API'sinin integration testleri yıllardır SQLite in-memory veritabanıyla koşuyor. Hepsi yeşil, CI pipeline'ı iki dakikada bitiyor. Sonra production'dan üç hata kaydı geliyor: bir müşteri aynı e-postayla ikinci kez kayıt olamıyor, katalog aramasında "clean code" yazan kullanıcı sonuç görüyor ama testler "arama büyük/küçük harfe duyarlı" diyor, bir kitabın fiyatı ekranda kayıtta girilenden farklı görünüyor.

Bunun nedenini görmek için aynı Entity Framework Core (EF Core) modelini ve aynı işlemleri üç provider'a karşı çalıştıran küçük bir program yazdım. SQL Server, Testcontainers ile açılan gerçek bir container; diğer ikisi Web API ve EF Core serilerinden tanıdığın SQLite in-memory ve EF Core InMemory provider'ı:

Dictionary<string, Func<BookstoreDbContext>> providers = new()
{
    ["SqlServer"] = () => new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>()
        .UseSqlServer(sql.GetConnectionString().Replace("Database=master", "Database=Diff")).Options),
    ["Sqlite"] = () => new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>()
        .UseSqlite(sqlite).Options),
    ["InMemory"] = () => new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>()
        .UseInMemoryDatabase("Diff").Options),
};

// Her provider için aynı senaryolar; örnek olarak ikisi:
await Run("email case", async () =>
{
    await using BookstoreDbContext db = create();
    db.Customers.Add(new Customer { Email = "ada@example.com", Name = "Ada" });
    db.Customers.Add(new Customer { Email = "Ada@Example.com", Name = "Ada" });
    await db.SaveChangesAsync();
    return "saved both";
});
await Run("search", async () =>
{
    await using BookstoreDbContext db = create();
    db.Books.Add(new Book { Isbn = "978-0132350884", Title = "Clean Code", Price = 54.905m, Stock = 12 });
    await db.SaveChangesAsync();
    string term = "clean code";
    int found = await db.Books.CountAsync(b => b.Title.Contains(term));
    return $"found {found}";
});

Modelde Title 200 karakterle sınırlı, Customer.Email üzerinde unique index var, kitaplar ISBN (uluslararası standart kitap numarası) ile tekil, Price için HasPrecision(10, 2) tanımlı. Senaryolar şunlar: 250 karakterlik başlık kaydetmek, yalnız büyük/küçük harfi farklı iki e-posta kaydetmek, birebir aynı iki e-posta kaydetmek, "clean code" araması, 54.905 olarak kaydedilen fiyatı geri okumak ve bir transaction'ı rollback etmek. Okumaya devam etmeden önce tahmin et: SQLite ve InMemory bu altı senaryonun kaçında SQL Server'la aynı sonucu verir?

Cevap: Aynı Kod, Üç Farklı Davranış

Programın gerçek çıktısı (SQL Server'ın truncation mesajındaki uzun değeri kısalttım):

== SqlServer
  long title: DbUpdateException -> String or binary data would be truncated in table 'Diff.dbo.Books', column 'Title'. Truncated value: 'xxxx...'.
  email case: DbUpdateException -> Cannot insert duplicate key row in object 'dbo.Customers' with unique index 'IX_Customers_Email'. The duplicate key value is (Ada@Example.com).
  exact duplicate: DbUpdateException -> Cannot insert duplicate key row in object 'dbo.Customers' with unique index 'IX_Customers_Email'. The duplicate key value is (grace@example.com).
  search: found 1
  price roundtrip: 54.91
  rollback: rows after rollback: 0
== Sqlite
  long title: saved
  email case: saved both
  exact duplicate: DbUpdateException -> SQLite Error 19: 'UNIQUE constraint failed: Customers.Email'.
  search: found 0
  price roundtrip: 54.905
  rollback: rows after rollback: 0
== InMemory
  long title: saved
  email case: saved both
  exact duplicate: saved both
  search: found 0
  price roundtrip: 54.905
  rollback: InvalidOperationException -> An error was generated for warning 'Microsoft.EntityFrameworkCore.Database.Transaction.TransactionIgnoredWarning': Transactions are not supported by the in-memory store. ...
SenaryoSQL Server 2025SQLite in-memoryEF Core InMemory
250 karakterlik başlıkhata (truncation)kaydedildikaydedildi
Ada@Example.com ikinci kezhata (unique index)kaydedildikaydedildi
birebir aynı e-postahatahatakaydedildi
"clean code" araması1 sonuç0 sonuç0 sonuç
54.905 fiyatı geri okumak54.9154.90554.905
transaction rollbackçalışırçalışırexception

SQLite altı senaryonun ikisinde, InMemory yalnız birinde SQL Server'la aynı davrandı. Farkların hepsi production'daki üç hatayla birebir eşleşiyor.

Büyük/küçük harf farkının kaynağı collation. Testcontainers'ın açtığı SQL Server container'ında sunucu collation'ını sorguladığımda SQL_Latin1_General_CP1_CI_AS döndü; adındaki CI "case-insensitive" demek. Bu yüzden unique index "ada@example.com" ile "Ada@Example.com"u aynı değer sayıyor, Contains araması da "Clean Code"u buluyor. SQLite metni varsayılan olarak ikili karşılaştırıyor, InMemory provider ise C#'ın sıralı (ordinal) string karşılaştırmasını kullanıyor. Yani SQLite'ta yazılan bir test, production'ın tam tersini "doğru davranış" diye kayda geçirebilir.

Uzunluk ve precision tanımları da yalnız gerçek veritabanında anlam taşıyor. SQL Server nvarchar(200) kolonuna 250 karakter yazdırmıyor ve decimal(10,2) kolonuna giden 54.905'i 54.91'e yuvarlıyor. SQLite'ta ne uzunluk sınırı ne de bu precision uygulanıyor; değer olduğu gibi geri geliyor. InMemory provider bir adım daha ileri gidip unique index'i hiç uygulamıyor: birebir aynı iki e-posta bile kaydedildi.

Transaction farkı ise testlerin kendisini bozuyor. InMemory transaction desteklemediği için BeginTransactionAsync çağrısı varsayılan olarak exception'a dönüşüyor; bu uyarıyı kapatırsan da rollback hiçbir şeyi geri almıyor. EF Core serisinin "EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları" yazısı bu provider'ların iç yapısını anlattı; bu seri oradan bir adım öteye gidiyor ve testleri production'daki veritabanı motoruyla koşturuyor.

Bu Seride "Integration Test" Ne Demek?

01-test-boundaries-tr.png

"Integration test" ekipten ekibe farklı anlama geliyor. Test türlerinin ayrımı ve hangi seviyede neyin test edileceği Test Stratejisi serisinin konusu; burada o tartışmayı tekrar açmayacağım. Bu serinin kullandığı tanım dar ve somut: API, test process'inin içinde, production'daki middleware pipeline'ı, routing'i, authorization'ı, endpoint'leri ve EF Core'uyla birlikte çalışır; veritabanı production'dakiyle aynı motorun gerçek bir instance'ıdır; ödeme, kargo ve e-posta gibi dış servisler ise HTTP veya SMTP (e-posta gönderim protokolü) sınırında taklit edilir.

Martin Fowler'ın 2018 tarihli IntegrationTest yazısındaki ayrım bu tanıma iyi oturuyor. Fowler, yalnız kendi service'inin dışarıyla konuşan kodunu test eden, diğer service'lerin yerine test double kullanan testlere "narrow integration test" diyor; bütün service'lerin canlı sürümlerini isteyen testleri ise "broad" sayıyor ve bunlara system ya da end-to-end test demeyi tercih ediyor. Bu serinin ilk beş yazısı narrow tarafta duruyor. Tarayıcıyla, Angular arayüzüyle ve bütün sistemi ayağa kaldırarak yapılan testler 6. yazıdan itibaren E2E (uçtan uca) bloğunda.

Görseldeki renkler bu yazının ve sonraki dördünün haritası. Yeşil olanlar production'daki kodun ve motorun aynısı: TestServer üzerinden geçen gerçek ASP.NET Core pipeline'ı ve Testcontainers'ın başlattığı SQL Server 2025. Pembe olanlar sınırda duran taklitler: ödeme ve kargo API'leri için 4. yazıda WireMock.Net, e-posta için sahte bir SMTP sunucusu. Sarı olan, test için bilerek değiştirdiğimiz tek service: JWT (JSON Web Token; imzalı erişim token'ı) doğrulaması yerine 2. yazıda yazacağımız test authentication handler'ı. Kırmızı çerçevedeki gerçek sağlayıcılar testten hiçbir zaman çağrılmıyor.

TestServer'ın yeri önemli. WebApplicationFactory varsayılan olarak Kestrel'i başlatmaz; istekler bir socket'e değil, bellekteki bir HttpMessageHandler'a gider. Port açılmaz, ağ yığınından geçilmez, ama request pipeline'ı production'dakiyle aynıdır. Bu yüzden integration test, HTTP seviyesinde bir istek atıp gerçek bir veritabanına kadar inen bir dilimi saniyenin küçük bir kesrinde test edebiliyor.

Kapsam dışında kalanlar da belli: RabbitMQ ve MassTransit testleri Message Queue serisinin 13. yazısında, service'ler arası Pact testleri Microservices serisinin 7. yazısında anlatıldı. Bu yazılarda yalnız HTTP API'sinin kendisi, veritabanı ve sınırdaki dış servisler var.

Web API 30'dan Bu Seriye Ne Değişti?

ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısı ilk integration testi .NET 6 ile kurdu: WebApplicationFactory, SQLite in-memory ile değiştirilen DbContext ve xUnit v2. O yazının temeli burada tekrar edilmiyor; değişenler şunlar:

KonuWeb API 30 (.NET 6)Bu seri (.NET 10)
VeritabanıSQLite in-memoryTestcontainers ile SQL Server 2025
Test frameworkxUnit v2 + VSTestxUnit v3 4.0 + Microsoft Testing Platform
Program erişimielle public partial class Programsource generator üretiyor
Paylaşımclass fixtureassembly fixture + class fixture
Dış servisleryokWireMock.Net, sahte SMTP, FakeTimeProvider

Test Projesinin İskeleti

Solution düzeni bilerek sade. Unit testler ve integration testler ayrı projelerde duruyor; nedenini aşağıda süreleriyle göreceğiz:

Bookstore.slnx
global.json
src/
    Bookstore.Api/
        Program.cs
        Data/                 // DbContext, entity'ler, migration'lar
        Endpoints/
        Integrations/         // PaymentClient, ShippingClient, SMTP sender
tests/
    Bookstore.Api.UnitTests/
    Bookstore.Api.IntegrationTests/

.NET 10 SDK'sında dotnet test iki runner'la çalışabiliyor. Unit Test serisinin "xUnit v3 ile Test Projesi: Microsoft Testing Platform, dotnet test ve İlk Testler" yazısındaki gibi MTP'yi (Microsoft Testing Platform; .NET'in yeni test çalıştırma altyapısı) global.json'dan seçiyoruz:

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

Integration test projesinin csproj'u üç paketten ibaret:

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

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
    <PackageReference Include="Testcontainers.MsSql" Version="4.16.0" />
    <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.3" />
  </ItemGroup>

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

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

</Project>

Microsoft.AspNetCore.Mvc.Testing paketinin sürümü API'nin hedeflediği ASP.NET Core sürümüyle aynı ailede olmalı. NuGet'te 11.0.0 da yayında, ama 11.0 paketi net11.0 içindir; .NET 10 projesinde 10.0.x serisinde kal.

Program'a erişmek artık elle değil

Top-level statement kullanan bir Program.cs'te derleyicinin ürettiği Program class'ı internal'dır. .NET 6'dan .NET 9'a kadar test projesinin WebApplicationFactory<Program> yazabilmesi için dosyanın sonuna şu satırı eklemek gerekiyordu:

// .NET 10'da gereksiz: source generator bu bildirimi zaten üretiyor
public partial class Program;

ASP.NET Core 10 ile gelen bir source generator, top-level statement kullanan uygulamalar için bu public partial bildirimi kendisi üretiyor. Bookstore'un Program.cs'inde böyle bir satır yok ve test projesi sorunsuz derleniyor. Eski bir projeyi yükselttiğinde satır duruyorsa, ASP0027 (ASP.NET Core'un analyzer kurallarından biri) kodlu analyzer onu gereksiz bildirim olarak işaretliyor. Severity'si Information olduğu için dotnet build çıktısında görünmüyor, IDE'de öneri olarak çıkıyor; kaldırmak davranışı değiştirmiyor.

İlk Test: Fixture, Factory ve Gerçek Bir İstek

Üç parça var: container'ı yöneten bir assembly fixture, API'yi bu veritabanına bağlayan bir factory ve testin kendisi. Container yaşam döngüsü, migration ve testler arası temizlik 3. yazının konusu; burada iskeletin çalışan en küçük hâli var.

using Bookstore.Api.Data;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MsSql;

[assembly: AssemblyFixture(typeof(Bookstore.Api.IntegrationTests.SqlServerFixture))]
[assembly: Trait("Category", "Integration")]

namespace Bookstore.Api.IntegrationTests;

public sealed class SqlServerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();

    public string ConnectionString { get; private set; } = "";

    public async ValueTask InitializeAsync()
    {
        await container.StartAsync();
        ConnectionString = new SqlConnectionStringBuilder(container.GetConnectionString())
        {
            InitialCatalog = "BookstoreDb"
        }.ConnectionString;

        // Şema test assembly'si başına bir kez, gerçek migration'larla kurulur
        DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
            .UseSqlServer(ConnectionString)
            .Options;
        await using BookstoreDbContext db = new(options);
        await db.Database.MigrateAsync();
    }

    public async ValueTask DisposeAsync() => await container.DisposeAsync();
}

xUnit v3'ün AssemblyFixture'ı, fixture'ı bütün test assembly'si için bir kez oluşturur; bütün test class'ları aynı container'ı kullanır. Unit Test serisinin "xUnit Test Yaşam Döngüsü: Constructor, IAsyncLifetime, Fixture'lar ve 4.0'da Tam Paralellik" yazısı fixture çeşitlerini anlattı. Integration testte assembly fixture'ın değeri somut: SQL Server container'ını başlatmak saniyeler sürüyor, bunu test class'ı başına ödemek istemezsin.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Bookstore.Api.IntegrationTests;

public sealed class BookstoreApiFactory(SqlServerFixture sql) : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        // appsettings.json'daki connection string'i container'ınkiyle ez
        builder.UseSetting("ConnectionStrings:Bookstore", sql.ConnectionString);
    }
}

Factory, assembly fixture'ı constructor'dan alıyor; xUnit v3 class fixture'ların constructor'ına assembly fixture'ları da enjekte edebiliyor. Web API 30'daki gibi DbContext registration'ını silip yenisini eklemiyoruz; veritabanı motoru aynı olduğu için yalnız connection string'i değiştirmek yetiyor. Configuration vermenin diğer yolları ve hangisinin Program.cs'te ne zaman görünür olduğu 2. yazıda.

using System.Net;
using System.Net.Http.Json;
using Bookstore.Api.Data;
using Bookstore.Api.Endpoints;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Api.IntegrationTests;

public sealed class BookCatalogTests(BookstoreApiFactory factory) : IClassFixture<BookstoreApiFactory>
{
    [Fact]
    public async Task Search_is_case_insensitive_like_production()
    {
        using (IServiceScope scope = factory.Services.CreateScope())
        {
            BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
            db.Books.Add(new Book { Isbn = "978-0132350884", Title = "Clean Code", Price = 54.90m, Stock = 12 });
            await db.SaveChangesAsync(TestContext.Current.CancellationToken);
        }

        HttpClient client = factory.CreateClient();
        List<BookDto>? books = await client.GetFromJsonAsync<List<BookDto>>(
            "/api/books?search=clean code", TestContext.Current.CancellationToken);

        // Yalnız bu testin eklediği kitaba bak; tablo başka testlerle paylaşılıyor
        BookDto book = Assert.Single(books!, b => b.Isbn == "978-0132350884");
        Assert.Equal("Clean Code", book.Title);
        Assert.Equal(54.90m, book.Price);
    }

    [Fact]
    public async Task Unknown_book_returns_404_problem_details()
    {
        HttpClient client = factory.CreateClient();

        HttpResponseMessage response = await client.GetAsync("/api/books/999999", TestContext.Current.CancellationToken);

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

İlk test SQLite'ta asla geçemeyecek bir davranışı doğruluyor: küçük harfle yapılan arama, büyük harfle kaydedilmiş başlığı buluyor. İkinci test, bilinmeyen bir kitap için 404 dönmesini ve gövdenin ProblemDetails formatında olmasını kontrol ediyor; bu, AddProblemDetails ve UseStatusCodePages middleware'lerinin gerçekten pipeline'da olduğunu da gösteriyor. Solution'ı çalıştırdığımda (dosya yollarını kısalttım):

$ dotnet test --solution Bookstore.slnx
Test run summary: Passed!
  .../Bookstore.Api.UnitTests.dll passed (290ms)
  .../Bookstore.Api.IntegrationTests.dll passed (18s 776ms)

  total: 5
  failed: 0
  succeeded: 5
  skipped: 0
  duration: 19s 128ms

Unit ve Integration Testleri Ayrı Tutmak

Bu iki satır, iki test türünü neden aynı projeye koymadığımı özetliyor. Unit test projesindeki üç test 290 milisaniyede bitti. Integration projesindeki iki test 18,8 saniye sürdü ve bu sürenin neredeyse tamamı container'ın başlaması ve migration'ın uygulanmasıydı; testlerin kendisi milisaniyeler. Aynı projede dursalar, bir hesaplama kuralını değiştiren geliştirici her kayıtta Docker'ın SQL Server açmasını beklerdi. Docker'ı olmayan bir makinede ise unit testler bile çalışamazdı, çünkü assembly fixture assembly'deki ilk test başlamadan önce kuruluyor.

Ayrı projeler ayrıca CI'da farklı adımlar demek: unit testler her push'ta ilk iş, integration testler Docker'lı runner'da ikinci iş. Pipeline'da test sırası ve quality gate'ler CI/CD Temelleri serisinin "Pipeline'da Test: Hızlı Geri Bildirim, Quality Gate'ler ve Flaky Test'ler" yazısında; GitHub Actions üzerinde kurulumu GitHub Actions serisinin 5. yazısında.

Fixture dosyasının başındaki [assembly: Trait("Category", "Integration")] satırı, bütün solution'ı çalıştırırken integration testleri filtrelemeyi sağlıyor. xUnit v3, Trait attribute'unu assembly seviyesinde de kabul ediyor. Filtreyi ilk denediğimde şu oldu:

$ dotnet test --solution Bookstore.slnx --filter-not-trait "Category=Integration"
Test run summary: Failed!
  .../Bookstore.Api.IntegrationTests.dll Zero tests ran (333ms)
  .../Bookstore.Api.UnitTests.dll passed (450ms)

  error: 1

  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 633ms
Test run completed with non-success exit code: 8 (see: https://aka.ms/testingplatform/exitcodes)

Hiçbir test kalmadı ama koşu başarısız sayıldı. Microsoft Testing Platform, hiç test çalıştırmayan bir test uygulamasını 8 numaralı çıkış koduyla bitiriyor ve dotnet test bunu hata kabul ediyor. Filtre bilerek bütün projeyi dışarıda bıraktığında bu kodu yok saymak gerekiyor:

$ dotnet test --solution Bookstore.slnx --filter-not-trait "Category=Integration" --ignore-exit-code 8
Test run summary: Passed!
  .../Bookstore.Api.UnitTests.dll passed (165ms)
  .../Bookstore.Api.IntegrationTests.dll passed (106ms)

  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 283ms

Güzel bir yan bilgi: integration projesi 106 milisaniyede kapandı. Hiçbir test seçilmediğinde assembly fixture kurulmuyor, yani container da başlamıyor.

Üretimde Dikkat

Integration suite için bir süre bütçesi koy ve onu ölç. Bookstore'da bütçe şöyle okunuyor: container ve migration için sabit bir başlangıç maliyeti, sonra test başına birkaç on milisaniye. Sabit maliyet test sayısından bağımsız olduğu için, testleri tek assembly'de toplamak ve container'ı assembly başına bir kez açmak süreyi doğrusal büyümekten kurtarıyor. Ölçtüğüm ortamda container'ın hazır olması birkaç saniye, toplam koşu 10 ile 19 saniye arasındaydı. Kendi makinende ve CI runner'ında ölç; CI'da image'ın önceden çekilip çekilmediği tek başına onlarca saniye fark ettirebilir.

Süre bütçesini aşan şey genelde test sayısı değil, test başına yapılan pahalı işler oluyor: her test için yeni factory, her test için yeni veritabanı, testin içinde container başlatmak. Bunların her birinin maliyetini 2. ve 3. yazılarda ölçeceğiz.

Integration testleri CI'da Docker'a bağımlı hâle geldi. Runner'da Docker yoksa Testcontainers testleri başlatırken hata veriyor; bu, sessizce atlanan testlerden iyidir. Ama ekipte Docker'ı olmayan biri varsa, unit test projesinin bağımsız kalması onun için tek güvenli geri bildirim döngüsü olacak.

Sık Düşülen Tuzaklar

Testlerin sıraya ve birbirinin verisine bağımlı olması. İlk sürümde arama testi Assert.Single(books!) ile bitiyordu. Sonra başka bir class'a şu test eklendi:

public sealed class BookEditionTests(BookstoreApiFactory factory) : IClassFixture<BookstoreApiFactory>
{
    [Fact]
    public async Task Second_edition_is_listed_with_its_own_price()
    {
        using (IServiceScope scope = factory.Services.CreateScope())
        {
            BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
            db.Books.Add(new Book { Isbn = "978-0135398579", Title = "Clean Code, 2nd Edition", Price = 61.50m, Stock = 4 });
            await db.SaveChangesAsync(TestContext.Current.CancellationToken);
        }

        HttpClient client = factory.CreateClient();
        List<BookDto>? books = await client.GetFromJsonAsync<List<BookDto>>(
            "/api/books?search=2nd edition", TestContext.Current.CancellationToken);

        Assert.Equal(61.50m, Assert.Single(books!).Price);
    }
}

Üç koşunun üçünde de eski arama testi kaldı:

failed Bookstore.Api.IntegrationTests.BookCatalogTests.Search_is_case_insensitive_like_production (16ms)
  Assert.Single() Failure: The collection contained 2 items
  Collection: [BookDto { Id = 2, Isbn = 978-0132350884, Title = Clean Code, Price = 54,90, Stock = 12 }, BookDto { Id = 1, Isbn = 978-0135398579, Title = Clean Code, 2nd Edition, Price = 61,50, Stock = 4 }]

İki test de kendi başına doğruydu; birlikte koştuklarında aynı tabloyu paylaştılar. Kalan test, kendi koymadığı bir satırı görüp patladı. Burada test sırası hep aynıydı ama bu bir şans: sıralama değiştiğinde ya da bir test daha eklendiğinde aynı sorun başka bir testi vurur. Kısa vadeli düzeltme, assertion'ı testin kendi oluşturduğu veriye daraltmak; yukarıdaki son sürüm bunu ISBN filtresiyle yapıyor. Kalıcı çözüm testler arası temizlik, 3. yazıda Respawn ve diğer seçenekleriyle.

İş kuralı kombinasyonlarını integration testte denemek. Bookstore'da bir sipariş 30 dakika içinde iptal edilebiliyor. Sınırın tam üstünü, bir dakika sonrasını ve sıfır dakikayı kontrol eden testler unit seviyesinde 290 milisaniyede bitti:

[Theory]
[InlineData(0, true)]
[InlineData(30, true)]
[InlineData(31, false)]
public void Order_can_be_cancelled_only_within_30_minutes(int minutesLater, bool expected)
{
    Assert.Equal(expected, OrderRules.CanCancel(PlacedAt, PlacedAt.AddMinutes(minutesLater)));
}

Aynı üç durumu HTTP üzerinden denemek her biri için sipariş oluşturmak, ödemeyi taklit etmek ve saati ilerletmek demek. Integration testin işi kuralın kendisini değil, kuralın endpoint'e doğru bağlandığını göstermek: bir kez "pencere içinde 200", bir kez "pencere dışında 409" yeter. Bunu 4. yazıda FakeTimeProvider ile yapacağız.

"Gerçek veritabanı" diye production veritabanının bir kopyasına ya da ekibin paylaştığı geliştirme sunucusuna bağlanmak. O zaman testler başkasının verisini görür, paralel koşan iki CI işi birbirinin kayıtlarını siler. Test veritabanı test koşusuna ait olmalı, koşuyla başlamalı ve koşuyla ölmeli.

InMemory provider'ı "hızlı integration test" diye kullanmak. Yukarıdaki tablo bunun neden integration test olmadığını gösteriyor: unique index, transaction, uzunluk ve precision kuralları olmadan veri katmanı test edilmiş sayılmaz.

Küçük Bir Deneme

★☆☆ Provider karşılaştırma programına DateTimeOffset içeren bir senaryo ekle: Order.CreatedAt değerine göre OrderBy yapan bir sorgu çalıştır ve üç provider'ın sonucunu karşılaştır. SQLite'ta hangi mesajı aldığını not et.

★★☆ BookstoreApiFactory'de UseSetting satırını kaldır ve testleri çalıştır. Hatanın hangi adımda ve hangi mesajla geldiğini oku; appsettings.json'daki connection string testte nereye bağlanmaya çalıştı?

★★★ Arama testini SQLite in-memory'ye karşı da koşan ikinci bir factory yaz ve aynı testi iki factory ile çalıştır. SQLite'ta da geçmesi için arama sorgusunu değiştirmen gerekirse (EF.Functions.Like, ToLower), bu değişikliğin SQL Server'da index kullanımını nasıl etkilediğini execution plan'da incele.

İskelet hazır ama factory henüz yalnız bir connection string değiştiriyor. Bir sonraki yazıda WebApplicationFactory'nin içine giriyoruz: custom factory'de service değiştirmek, configuration'ın Program.cs'e hangi anda ulaştığı, test authentication handler'ıyla 401 ve 403'ü ayırmak ve background service'lerin testte istemeden çalışması.