撰寫 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 後,六項測試全過,成功。

Introduce to how to use EF Core In-Memory Provider and SQLite In-Memory Mode in unit tests.


Comments

# by Quintos

请问您用的SQLite是微软的还是SQLite 官方的? 据说官方SQLite 要比微软的性能好

# by Jeffrey

to Quintos, 請見新文章 https://blog.darkthread.net/blog/sqlitepclraw-version/

Post a comment