6

EF Core 無痕單元測試 - In-Memory Provider 與 SQLite In-Memory Mode

 3 years ago
source link: https://blog.darkthread.net/blog/ef-core-test-with-in-memory-db/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
In-Memory Provider 與 SQLite In-Memory Mode-黑暗執行緒

撰寫 EF Core 相關測試時,若偏向單元測試性質,除了真的連接資料庫實測試,若測試內容未高度依賴資料庫特性,還有更輕便、易控制且有效率的選擇。 使用真實資料庫是最省事最逼真的做法,但實務上可能會遇到困難,例如:

  • 因軟硬體資源或網路限制,未必有專供測試的資料庫可用。
  • 實際連線資料庫速度太慢,單元測試的速度要快,要實踐 CI/CD,隨時隨地想測就測,馬上有結果的測試是成功關鍵之一。
  • 測試常涉及資料寫入,當有多組測試同時進行時,使用實體資料庫很難避免打架,而且測完還得還原才不會干擾下次的結果。

基於以上考量,讓每個測試擁有自己專屬的模擬測試資料庫,彼此不互相干擾,測完就消失,不用清理還原,是更理想的做法。微軟的這篇 EF Core / Testing / Choosing a testing strategy 提供了一些建議:

  • 使用 Dummy、Fake、Stubs、Spies、Mocks 等測試替身 (Test Double)
  • 一些資料庫有提供開發者版本,可考慮 LocalDB、Docker 裝資料庫的方案,執行慢一點但高度擬真(EF Core 用 LocalDB 執行 30,000 個測試,CI 程序中每次 Commit 花數分鐘跑完),若速度可被接受,這是最省事的無腦解法。
  • EF Core In-Memory Provider,用記憶體模擬簡單資料庫行為,簡單輕巧但有些限制。原本是 EF Core 內部測試用,但也有開發者拿來跑單元測試。
  • SQLite In-Memory Mode 存在記憶體,可實現每個測試自己一份,不互相干擾,測完即逝,不留痕跡。基本關聯式資料庫行為差不多都有,有時可替代 SQL 使用。

這篇筆記將示範如何使用 EF Core In-Memory Provider 及 SQLite In-Memory Mode 測試。

使用 EF Core In-Memory Provider 前需留言它有些重大限制:

  1. 它不是關聯式資料庫,有些 LINQ 查詢會失敗
  2. 不支援 Transaction
  3. 不支援用 FromSqlRaw()、ExecuteSqlRaw() 直接跑 SQL 指令
  4. 效能未最佳化,無法透過 Index 等機制加速,應跟查詢記憶體的 LINQ 物件差不多 (延伸閱讀:當心 LINQ 搜尋的效能陷阱)

我設計一個簡單 MyDbContext 及一組測試,用以展示二者差異。

MyDbContext 內容如下,共一個 Players 資料表,PlayerId 自動跳號,Name 是名稱,加上 Unique Index 禁止重複。

public class Player
{
    public int PlayerId { get; set; }
    public string Name { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<Player> Players { get; set; }

    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Player>().HasIndex(o => o.Name).IsUnique();
    }
}

測試共有六個,T01_TestInsert3 寫入三筆檢查數量為 3、T02_TestInsert2 寫入兩筆數量為 2 確保未受 T01 影響、T03_TestAutoId 檢查自動跳號、T04_TestUnique 檢查 Unique Index、T05_TestTrans 測試交易、T06_TestRawSql 測試直接執行 SELECT FROM WHERE:

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.EntityFrameworkCore;
using System.Threading;
using System.Linq;
using System;

namespace test_demo_dbctx;

[TestClass]
public class UnitTest1
{
    public MyDbContext CreateInMemDbContext()
    {
        var inMemDbName = "D" + Guid.NewGuid().ToString();
        var opt = new DbContextOptionsBuilder<MyDbContext>()
            .UseInMemoryDatabase(inMemDbName).Options;
        return new MyDbContext(opt);
    }

    public void Insert(MyDbContext dbCtx, params string[] names)
    {
        foreach (var n in names)
            dbCtx.Players.Add(new Player { Name = n });
        dbCtx.SaveChanges();
    }

    [TestMethod]
    public void T01_TestInsert3()
    {
        var d = CreateInMemDbContext();
        Insert(d, "P1", "P2", "P3");
        Assert.AreEqual(3, d.Players.Count());
    }

    [TestMethod]
    public void T02_TestInsert2()
    {
        var d = CreateInMemDbContext();
        Insert(d, "P1", "P2");
        //不受前次測試影響
        Assert.AreEqual(2, d.Players.Count());
    }

    [TestMethod]
    public void T03_TestAutoId()
    {
        var d = CreateInMemDbContext();
        Insert(d, "P1", "P2");
        var list = d.Players.OrderBy(x => x.PlayerId).ToList();
        Assert.AreEqual(2, list.Count());
        Assert.AreEqual(
            list.First().PlayerId + 1,
            list.Last().PlayerId);
    }

    [TestMethod]
    public void T04_TestUnique()
    {
        var d = CreateInMemDbContext();
        Insert(d, "Unique");
        try
        {
            Insert(d, "Duplicated", "Duplicated");
            Assert.Fail("Unique index failed");
        }   
        catch (DbUpdateException ex)
        {
            Assert.AreEqual(d.Players.Count(), 1);
        }
    }

    [TestMethod]
    public void T05_TestTrans()
    {
        var d = CreateInMemDbContext();
        using (var t = d.Database.BeginTransaction())
        {
            Insert(d, "A");
            Insert(d, "B");
            t.Rollback();
        }
        Assert.AreEqual(0, d.Players.Count());
    }

    [TestMethod]
    public void T06_TestRawSql()
    {
        var d = CreateInMemDbContext();
        Insert(d, "A", "B");
        var res = d.Players.FromSqlRaw("SELECT * FROM Players WHERE Name = 'B'").FirstOrDefault();
        Assert.IsNotNull(res);
        Assert.AreEqual(res.Name, "B");
    }

}

實測結果,三好三壞,Unique Index、Transaction、FromSqlRaw 未通過測試。

測試失敗訊息分別為:

Assert.Fail failed. Unique index failed
Transactions are not supported by the in-memory store.
Assert.AreEqual failed. Expected:<A>. Actual:<B>.

接著,我們將資料換成 SQLite In-Memory Mode,連線字串寫為 "data source=:memory:",SQLite 將在 SqliteConnection 連線建立期間在記憶體建立 SQLite 資料庫,連線中斷即消除,採用建好連線物件當成 UseSqlite() 參數的做法讓 DbContext 使用該 SQLlite。(另一種做法是寫成,Data Source=InMemoryDbName;Mode=Memory;Cache=Shared,以名稱為區別,讓同一連線字串的 SqliteConnection 共用同一 SQLite In-Memory 資料庫,參考)

    public MyDbContext CreateInMemDbContext()
    {
        var cn = new SqliteConnection("data source=:memory:");
        cn.Open();
        var opt = new DbContextOptionsBuilder<MyDbContext>()
            .UseSqlite(cn).Options;
        var d = new MyDbContext(opt);
        d.Database.EnsureCreated();
        return d;
    }

更換為 SQLite In-Memory 後,六項測試全過,成功。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK