EF Core 無痕單元測試 - In-Memory Provider 與 SQLite In-Memory Mode
2 |
撰寫 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 前需留言它有些重大限制:
- 它不是關聯式資料庫,有些 LINQ 查詢會失敗
- 不支援 Transaction
- 不支援用 FromSqlRaw()、ExecuteSqlRaw() 直接跑 SQL 指令
- 效能未最佳化,無法透過 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/