Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #22

EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları

Mert Özen 5 Kas 2026 10 dk 3 görüntülenme
EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları

InMemory provider'ın unique index'i, foreign key'i ve raw SQL'i nasıl sessizce geçtiğini gerçek test çıktılarıyla görüyor, SQLite in-memory ile gerçekçi testler kuruyoruz. Aynı context'te assert etmenin yarattığı yalancı yeşili ve DbContext'i soyutlama tartışmasını EF dokümantasyonunun önerileriyle ele alıyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Bu Test Geçer mi?

Kitapların ISBN'i benzersiz olmalı. Bunu modelde bir unique index ile söyledik:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>().HasIndex(b => b.Isbn).IsUnique();
}

Şimdi bu kuralı test eden, hızlı ve kurulum gerektirmeyen bir test yazalım. Bunun için EF Core'un InMemory provider'ını kullanıyoruz:

public class InMemoryTests
{
    private static BookstoreContext CreateContext(string name)
        => new(new DbContextOptionsBuilder<BookstoreContext>()
            .UseInMemoryDatabase(name)
            .Options);

    [Fact]
    public async Task InMemory_duplicate_isbn_is_rejected()
    {
        using var db = CreateContext(Guid.NewGuid().ToString());
        db.Books.Add(TestData.Book(isbn: "978-0441172719"));
        db.Books.Add(TestData.Book(title: "Dune (copy)", isbn: "978-0441172719"));

        await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
    }
}

Aynı ISBN'li iki kitap ekliyoruz ve kaydın reddedilmesini bekliyoruz. Test geçer mi? dotnet test çıktısı (EF Core 6; EF Core 10'da yalnızca süre farklı):

Failed Bookstore.Tests.InMemoryTests.InMemory_duplicate_isbn_is_rejected [3 ms]
  Error Message:
   Assert.Throws() Failure: No exception was thrown
Expected: typeof(Microsoft.EntityFrameworkCore.DbUpdateException)

Hiçbir exception yok. İki kitap da mutlu mesut kaydedildi. Production'daki veritabanı bu kaydı reddedecekken test ortamı kabul ediyor. Yani test, korumak istediğimiz kuralı ölçemiyor. Bu yazıda EF Core ile yazılan kodu neyle test etmemiz gerektiğini, SQLite in-memory ile gerçekçi testleri, aynı context'te assert etmenin yarattığı yalancı yeşilleri ve DbContext'i soyutlama tartışmasını konuşacağız.

InMemory Provider Aslında Ne?

InMemory provider ilişkisel bir veritabanı değil. EF Core'un kendi ekibinin test altyapısı için yazdığı, entity'leri bellekte tutan basit bir depo. SQL üretmiyor, constraint bilmiyor, transaction bilmiyor. EF dokümantasyonu, onu uygulama testlerinde veritabanı yerine kullanmayı açıkça "highly discouraged" diye niteliyor. Aynı modelle birkaç deneme yaptım; sonuçlar EF Core 6 ve 10'da şöyle:

Unique index uygulanmıyor, az önce gördük. Foreign key de uygulanmıyor: var olmayan BookId = 999 ile bir OrderItem kaydedince SaveChangesAsync 2 döndürüyor, hiçbir hata yok. Transaction açmaya çalışınca exception geliyor, çünkü InMemory'nin "transaction'ı yok sayıyorum" uyarısı varsayılan olarak hataya çevrilmiş (EF Core 10 çıktısı; EF Core 6'da tek fark linkin http:// ile başlaması):

System.InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Database.Transaction.TransactionIgnoredWarning': Transactions are not supported by the in-memory store. See https://go.microsoft.com/fwlink/?LinkId=800142 This exception can be suppressed or logged by passing event ID 'InMemoryEventId.TransactionIgnoredWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

Raw SQL ise sürüme göre değişen bir sürpriz. db.Books.FromSqlRaw("SELECT * FROM Books") EF Core 10'da Query root of type 'FromSqlQueryRootExpression' wasn't handled by provider code. diye exception fırlatıyor. EF Core 6'da ise hiçbir şey olmuyor: SQL sessizce yok sayılıyor ve tablodaki bütün kitaplar geliyor. EF Core 7'nin breaking change listesi bunu açıkça yazıyor: EF Core 6'da relational method'ları InMemory'de kullanmak sessiz bir no-op'tu. Denedim: iki kitap varken FromSqlRaw("SELECT * FROM Books WHERE 1 = 0") EF Core 6'da iki kitap döndürdü. Yani WHERE içeren bir raw SQL'i test ettiğini sanırken aslında hiçbir şeyi test etmiyordun.

Son bir fark da metin karşılaştırması. InMemory, C#'taki gibi büyük/küçük harfe duyarlı karşılaştırıyor; Title == "dune" sorgusu "Dune"u bulmuyor. SQL Server ise varsayılan olarak duyarsız karşılaştırır. Aynı sorgu test ortamında boş, production'da dolu dönebilir.

InMemory'yi sahte bir uçuş simülatörü gibi düşün. Düğmeler yerinde, ekran yanıp sönüyor, ama yerçekimi yok. Kalkışı çalışmak için iyi, iniş için güvenme.

SQLite In-Memory: Gerçek Veritabanı, Sıfır Kurulum

Daha iyi bir seçenek, gerçek bir ilişkisel veritabanını bellekte çalıştırmak. SQLite bunu destekliyor ve zaten serinin her örneğinde kullandığımız provider. Constraint'ler, transaction'lar, SQL üretimi, raw SQL hepsi gerçek. Test projesinin paketleri:

<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.36" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.36" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
  <PackageReference Include="xunit" Version="2.9.3" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

EF Core 10 ile çalışıyorsan iki EF paketinin sürümünü 10.0.14 yapman yeterli; InMemory paketi yalnızca karşılaştırma testleri için burada. Her test için temiz bir veritabanı kuran küçük bir yardımcı class:

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

public sealed class SqliteDatabase : IDisposable
{
    private readonly SqliteConnection _connection;

    public SqliteDatabase()
    {
        _connection = new SqliteConnection("DataSource=:memory:");
        _connection.Open();

        using var db = CreateContext();
        db.Database.EnsureCreated();
    }

    public BookstoreContext CreateContext()
        => new(new DbContextOptionsBuilder<BookstoreContext>()
            .UseSqlite(_connection)
            .Options);

    public void Dispose() => _connection.Dispose();
}

Kritik nokta açık tutulan connection. SQLite'ın in-memory veritabanı, ona bağlı son connection kapandığı anda yok oluyor. Biz connection'ı bir kez açıp bütün context'lere aynı nesneyi veriyoruz; veritabanı Dispose çağrılana kadar yaşıyor. Bunun neden şart olduğunu tuzaklarda gerçek hatasıyla göreceğiz.

Aynı yöntemle önceki yazıdaki interceptor'ı ya da value converter'ı da test edebilirsin: CreateContext'te AddInterceptors(...) ekle, kaydet, sonra yeni bir context'le oku.

Test Edilen Kod: OrderService

Testlerimizin hedefi, sipariş veren küçük bir service:

public class OrderService
{
    private readonly BookstoreContext _db;

    public OrderService(BookstoreContext db) => _db = db;

    public async Task<Order> PlaceOrderAsync(string customerEmail, int bookId, int quantity)
    {
        var book = await _db.Books.SingleAsync(b => b.Id == bookId);
        if (book.Stock < quantity)
            throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");

        book.Stock -= quantity;
        var order = new Order
        {
            CustomerEmail = customerEmail,
            Total = book.Price * quantity,
            Items = { new OrderItem { BookId = book.Id, Quantity = quantity, UnitPrice = book.Price } }
        };
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();
        return order;
    }
}

Testte üç ayrı context kullanıyoruz: biri veriyi hazırlamak, biri işi yaptırmak, biri de sonucu kontrol etmek için. Production'da da her request kendi context'ini alıyor; testin bu gerçeği taklit etmesi istiyoruz:

public class OrderServiceTests : IDisposable
{
    private readonly SqliteDatabase _database = new();

    public void Dispose() => _database.Dispose();

    [Fact]
    public async Task PlaceOrder_decreases_stock_and_calculates_total()
    {
        int bookId;
        using (var arrange = _database.CreateContext())
        {
            var book = TestData.Book(price: 12.5m, stock: 5);
            arrange.Books.Add(book);
            await arrange.SaveChangesAsync();
            bookId = book.Id;
        }

        using (var act = _database.CreateContext())
        {
            var order = await new OrderService(act).PlaceOrderAsync("ada@example.com", bookId, 2);
            Assert.Equal(25m, order.Total);
        }

        using var assert = _database.CreateContext();
        var stored = await assert.Books.SingleAsync(b => b.Id == bookId);
        Assert.Equal(3, stored.Stock);
        Assert.Equal(1, await assert.Orders.CountAsync());
    }

    [Fact]
    public async Task PlaceOrder_throws_when_stock_is_insufficient()
    {
        using var db = _database.CreateContext();
        var book = TestData.Book(stock: 1);
        db.Books.Add(book);
        await db.SaveChangesAsync();

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => new OrderService(db).PlaceOrderAsync("ada@example.com", book.Id, 3));
        Assert.Equal("Only 1 copies of 'Dune' left.", ex.Message);
    }

    [Fact]
    public async Task Duplicate_isbn_is_rejected()
    {
        using var db = _database.CreateContext();
        db.Books.Add(TestData.Book(isbn: "978-0441172719"));
        db.Books.Add(TestData.Book(title: "Dune (copy)", isbn: "978-0441172719"));

        await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
    }
}

xUnit her test için class'ın yeni bir instance'ını oluşturuyor; yani her test kendi SqliteDatabase'ini, dolayısıyla kendi boş veritabanını alıyor. Üç test de iki sürümde geçiyor. Özellikle sonuncusuna dikkat et: InMemory'de kırmızı yanan ISBN testi burada yeşil, çünkü SQLite unique index'i gerçekten uyguluyor.

Yalancı Yeşil: Aynı Context'te Assert Etmek

Service'e stok ekleyen bir method daha yazdık. Hızlı yazdık ve bir şeyi unuttuk:

public async Task RestockAsync(int bookId, int amount)
{
    var book = await _db.Books.SingleAsync(b => b.Id == bookId);
    book.Stock += amount;
    // hata: SaveChangesAsync çağrısı unutuldu
}

Testini de tek context'le yazalım:

[Fact]
public async Task Restock_increases_stock()
{
    using var db = _database.CreateContext();
    var book = TestData.Book(stock: 1);
    db.Books.Add(book);
    await db.SaveChangesAsync();

    await new OrderService(db).RestockAsync(book.Id, 10);

    var reloaded = await db.Books.SingleAsync(b => b.Id == book.Id);
    Assert.Equal(11, reloaded.Stock);
}

Test geçiyor. Ama veritabanında stok hâlâ 1. Sebebi change tracking yazısında gördüğümüz identity resolution: SingleAsync SQL'i gönderiyor, gelen satırın key'i zaten takip edilen bir entity'ye ait olduğu için EF yeni nesne üretmiyor ve bellekteki, değiştirilmiş ama kaydedilmemiş nesneyi döndürüyor. Test veritabanını değil, change tracker'ı okudu.

Assert'ten önce tek satır ekle:

db.ChangeTracker.Clear();
var reloaded = await db.Books.SingleAsync(b => b.Id == book.Id);
Assert.Equal(11, reloaded.Stock);

Ve gerçek ortaya çıksın (EF Core 6; EF Core 10'da da aynı hata):

Failed Bookstore.Tests.OrderServiceTests.Restock_increases_stock [35 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 11
Actual:   1

Kural: sonucu her zaman veritabanından oku. Ya ayrı bir context aç, ya da ChangeTracker.Clear() çağır. İlk testteki arrange/act/assert context'leri tam olarak bu yüzden ayrı.

Test Verisi: Küçük ve Açık Builder'lar

Testlerde hep TestData.Book(...) kullandık. Arkasında sadece varsayılan değerleri olan bir method var:

public static class TestData
{
    public static Book Book(string title = "Dune", decimal price = 10m, int stock = 5, string? isbn = null)
        => new() { Title = title, Price = price, Stock = stock, Isbn = isbn ?? Guid.NewGuid().ToString("N") };
}

Bu küçük class iki iş görüyor. Birincisi, test yalnızca kendisi için önemli olan değeri yazıyor: stok testinde stock: 1, fiyat testinde price: 12.5m. Okuyan kişi hangi değerin neden orada olduğunu hemen görüyor. İkincisi, ISBN gibi benzersiz olması gereken alanlar varsayılan olarak benzersiz üretiliyor; iki kitap ekleyen bir test, ISBN'le ilgilenmiyorsa unique index'e takılmıyor.

Bütün testlerin paylaştığı büyük bir seed dosyası cazip görünür ama zamanla herkesin dokunduğu, kimsenin silmeye cesaret edemediği bir yapıya dönüşür. Bir testin verisi o testin içinde dursun. Model seeding (HasData) ile verdiğin referans verisi ise EnsureCreated ile zaten tabloya geliyor; onu test verisiyle karıştırma.

DbContext'i Soyutlamak Gerekli mi?

Sık duyulan bir öneri: "Service DbContext'e bağımlı olmasın, bir IBookstoreContext arayüzü yaz, testte DbSet'i mock'la." EF dokümantasyonu bu noktada net: sorgu tarafında DbSet'i gerçekten mock'lamak mümkün değil, çünkü LINQ operatörleri IQueryable üzerindeki static extension method'lar. "DbSet mock'ladım" diyen kişi aslında bellekteki bir listeyi DbSet kılığına sokmuş oluyor ve sorguları LINQ to Objects olarak çalıştırıyor. Çevrilemeyen bir ifade, yanlış bir Include ya da eksik bir SaveChanges testte görünmüyor. Dokümantasyonun tavsiyesi kısa: sorgu için DbSet mock'lamaktan kaçın.

Peki ne öneriyor? Önce production'daki veritabanı sistemine karşı test yazmayı. Hızlı ve izole kurulduğunda bu testler sandığın kadar yavaş değil; SQL Server için Docker'da gerçek bir veritabanı ayağa kaldıran Testcontainers gibi araçlar işi kolaylaştırıyor. Veritabanını tamamen devreden çıkaran bir test double şartsa, dokümantasyon SQLite ya da InMemory gibi sahte bir provider yerine EF Core'un üstüne bir repository katmanı koyup onu mock'lamayı öneriyor; bedelinin de kod ve bakım olduğunu ekliyor. Bu tartışmayı bir sonraki yazıda mimari açıdan derinleştireceğiz.

SQLite in-memory bu iki uç arasında pratik bir orta yol, ama sınırlarını bilerek. O da senin production veritabanın değil. Dokümantasyonun verdiği örnek tam olarak az önce InMemory'de gördüğümüz şey: SQL Server metinleri varsayılan olarak büyük/küçük harfe duyarsız karşılaştırır, SQLite duyarlı. EF.Functions.DateDiffDay gibi SQL Server'a özgü bir fonksiyon SQLite'ta hiç çalışmaz, rowversion da yoktur. Benim tercih ettiğim denge şu: iş kurallarını ve EF Core davranışını sınayan hızlı testlerin çoğu SQLite in-memory ile, provider'a özgü her şey ve kritik akışlar az sayıda gerçek veritabanı testiyle.

Sık Düşülen Tuzaklar

Birinci tuzak, connection yerine connection string vermek:

var options = new DbContextOptionsBuilder<BookstoreContext>()
    .UseSqlite("DataSource=:memory:")
    .Options;

using var db = new BookstoreContext(options);
db.Database.EnsureCreated();
db.Books.Add(TestData.Book());
await db.SaveChangesAsync();
Microsoft.EntityFrameworkCore.DbUpdateException : An error occurred while saving the entity changes. See the inner exception for details.
---- Microsoft.Data.Sqlite.SqliteException : SQLite Error 1: 'no such table: Books'.

EF her işlem için connection'ı açıp kapatıyor. EnsureCreated tabloyu oluşturdu, connection kapandı, bellekteki veritabanı yok oldu. SaveChangesAsync yeni bir connection'la yepyeni, boş bir veritabanına bağlandı. Connection'ı kendin aç ve açık tut.

İkinci tuzak, InMemory'de veritabanı adını paylaşmak. UseInMemoryDatabase("Bookstore") aynı adla açılan her context'e aynı depoyu veriyor; bir testin eklediği kitap diğer testte görünüyor ve testler çalışma sırasına göre geçip kalıyor. InMemory'yi kullanmak zorundaysan her test için Guid.NewGuid().ToString() gibi benzersiz bir ad ver.

Üçüncü tuzak, EnsureCreated'in migration'ları test etmediğini unutmak. EnsureCreated şemayı doğrudan modelden kurar, migration dosyalarına bakmaz. Testlerin yeşil olması, migration'ların doğru olduğunu göstermez. Migration'ları ayrıca, gerçek bir veritabanına Migrate() ile uygulayarak dene.

Dördüncüsü, aynı context'i hem act hem assert için kullanmak; yukarıda yalancı yeşilini gördük. Beşincisi de testte SQL'i görmemek. Bir test beklenmedik şekilde kırıldığında CreateContext'e geçici olarak .LogTo(Console.WriteLine) eklemek, sorunun yarısını çözer.

Küçük Bir Deneme

★☆☆ RestockAsync'e eksik SaveChangesAsync çağrısını ekle ve ChangeTracker.Clear() içeren testin yeşile döndüğünü gör.

★★☆ InMemory provider ile bir OrderItem'ı var olmayan BookId = 999 ile kaydeden bir test yaz ve DbUpdateException bekle. Aynı testi SqliteDatabase ile çalıştır. İki sonucu karşılaştır ve hangi testin production'daki davranışı yansıttığını söyle.

★★★ Önceki yazıdaki AuditInterceptor'ı SqliteDatabase.CreateContext'e ekle ve iki test yaz: yeni bir kitapta CreatedAt'in dolduğunu, güncellenen bir kitapta da CreatedAt'in değişmediğini doğrulasın. Zamanı test edilebilir yapmak için interceptor'a Func<DateTime> alan bir constructor ekle.

Artık EF Core kodunu güvenle test edebiliyoruz. Geriye büyük resim kalıyor: DbContext'in üzerine repository ve unit of work koymalı mıyız, CQRS'in okuma tarafında EF Core nasıl kullanılır ve ne zaman Dapper'a geçmek gerekir? Kemik yapının son yazısında EF Core'u mimari açıdan masaya yatıracağız.