當我們要針對商業邏輯測試時,可能需要隔離 EFCore DbContext,搭配 Mock Framework 可以快速地建立測試替身假的 DbContext,自從 EF Core 的 In-Memory 出現之後,建立 DbContext 測試替身這件事,就變得輕鬆許多了
物件依賴關係
Biz 商業邏輯不應該知道 Repository 背後的資料依賴關係,只能看到到他們雙方的合約 (Interface 方法、屬性)
我希望 Repository 可以
- 自我管理 DbContext 的生命週期,預設連線字串依賴外部檔案
- DbContext 存取修飾為 internal ,只有特定物件(測試專案)可以注入 In-Memory 的組態或是改變連線字串
這裡有幾種 DbContext 的建立方式,你可以根據你的需求建立你想要的 DbContext
DbContext 請根據你的需求設計,需求不等於資料表
由於物件封裝的程度可能會不一樣,閱讀完本篇之後,請依照你自己的需求另外處理
開發環境
- Rider 2021.3.4
- Windows 10
- Entity Framework 5.0.5
建立 Lib 專案
新增一個 .NET 5 Lib 專案,名為 Lab.DAL,並在 Rider 的 Terminal 視窗輸入以下命令
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 5.0.5
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 5.0.5
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 5.0.5
dotnet add package Microsoft.Extensions.Logging.Console --version 5.0.0
dotnet add package Microsoft.Extensions.Configuration.Json --version 5.0.0
注意,執行 CLI 時,得停留在專案根目錄
執行結果如下圖:
新增 appsettings.json,內容如下:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Lab.DAL;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}
EmployeeDbContext
為了要用 EF Core CLI 產生 Migrate file,又為了要能夠由外部注入改變 DbContext,EmployeeContext 的建構函數依賴 DbContextOptions<EmployeeContext>
public class EmployeeDbContext : DbContext
{
private static readonly bool[] s_migrated = {false};
public virtual DbSet<Employee> Employees { get; set; }
public virtual DbSet<Identity> Identities { get; set; }
public virtual DbSet<OrderHistory> OrderHistories { get; set; }
public EmployeeDbContext(DbContextOptions<EmployeeDbContext> options)
: base(options)
{
if (s_migrated[0])
{
return;
}
lock (s_migrated)
{
if (s_migrated[0] == false)
{
var memoryOptions = options.FindExtension<InMemoryOptionsExtension>();
if (memoryOptions == null)
{
this.Database.Migrate();
}
s_migrated[0] = true;
}
}
}
// 給 Migration CLI 使用
// 建構函數配置失敗才需要以下處理
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
...
}
//管理索引
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
...
}
}
我會習慣這樣做
- 準備一個 localdb 用來產生 migration file。
- 另一個 localdb 給測試使用。
- 透過建構函數調用 this.Database.Migrate(),自動更新資料結構。
EmployeeDbContext 和其他 Model 完整代碼如下:
- sample.dotblog/EmployeeDbContext.cs at master · yaochangyu/sample.dotblog (github.com)
- sample.dotblog/Employee.cs at master · yaochangyu/sample.dotblog (github.com)
- sample.dotblog/Identity.cs at master · yaochangyu/sample.dotblog (github.com)
- sample.dotblog/OrderHistory.cs at master · yaochangyu/sample.dotblog (github.com)
可以在 DbContext.OnConfiguring 重新配置 DbContext,但我不打算使用他
// 給 Migration CLI 使用
// 建構函數配置失敗才需要以下處理
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
...
}
EmployeeContextFactory
新增 IDesignTimeDbContextFactory<EmployeeContext> 讓 .NET Core CLI、Package Manager Console in Visual Studio 可以套用
public class EmployeeContextFactory : IDesignTimeDbContextFactory<EmployeeContext>
{
public EmployeeContext CreateDbContext(string[] args)
{
Console.WriteLine("由設計工具產生 Database,初始化 DbContextOptionsBuilder");
var config = DefaultDbContextManager.Configuration;
var connectionString = config.GetConnectionString("DefaultConnection");
Console.WriteLine($"由 appsettings.json 讀取連線字串為:{connectionString}");
var optionsBuilder = new DbContextOptionsBuilder<EmployeeContext>();
optionsBuilder.UseSqlServer(connectionString);
Console.WriteLine($"DbContextOptionsBuilder 設定完成");
return new EmployeeContext(optionsBuilder.Options);
}
}
sample.dotblog/EmployeeContextFactory.cs at master · yaochangyu/sample.dotblog (github.com)
DefaultDbContextManager
DefaultDbContextManager 是用來集中管理 Repository 所需要的物件,可以選擇用 DI Container 或是自己用靜態欄位、方法封裝起來。
- 存取修飾詞 internal,只能給測試專案呼叫,詳細作法請參考 .NET Project SDKs 設定 InternalsVisibleTo
設定如下:
我選擇用 MS DI Container 管理複雜的型別
- 註冊 IDbContextFactory<EmployeeDbContext>,調用 services.AddDbContextFactory<EmployeeDbContext>(ApplyConfigurePhysical)
AddDbContextFactory 預設的生命週期為 ServiceLifetime.Singleton
public static IServiceCollection AddDbContextFactory<TContext>(
[NotNull] this IServiceCollection serviceCollection,
[CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,
ServiceLifetime lifetime = ServiceLifetime.Singleton)
where TContext : DbContext
=> AddDbContextFactory<TContext, DbContextFactory<TContext>>(serviceCollection, optionsAction, lifetime);
用靜態欄位管理簡單的型別
- Now:型別 DateTime
如果你不喜歡這樣用靜態欄位注入,堅持通通都要用 DI Container ,也可以用 ISystemClock
- 有依賴 Microsoft.AspNetCore.Authentication.dll 的 ISystemClock
原始碼:aspnetcore/ISystemClock.cs at main · dotnet/aspnetcore (github.com)
namespace Microsoft.AspNetCore.Authentication
{
/// <summary>
/// Abstracts the system clock to facilitate testing.
/// </summary>
public interface ISystemClock
{
/// <summary>
/// Retrieves the current system time in UTC.
/// </summary>
DateTimeOffset UtcNow { get; }
}
}
實作 aspnetcore/SystemClock.cs at main · dotnet/aspnetcore (github.com),
public class SystemClock : ISystemClock
{
/// <summary>
/// Retrieves the current system time in UTC.
/// </summary>
public DateTimeOffset UtcNow
{
get
{
// the clock measures whole seconds only, to have integral expires_in results, and
// because milliseconds do not round-trip serialization formats
var utcNowPrecisionSeconds = new DateTime((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
return new DateTimeOffset(utcNowPrecisionSeconds);
}
}
}
- 自訂義 ISystemClock,不依賴 Microsoft.AspNetCore.Authentication.dll
完整代碼如下:
internal class DefaultDbContextManager
{
public static readonly bool[] Migrated = {false};
private static readonly Lazy<ServiceProvider> s_serviceProviderLazy;
private static readonly Lazy<IConfiguration> s_configurationLazy;
private static readonly ILoggerFactory s_loggerFactory;
private static readonly ServiceCollection s_services;
private static ServiceProvider s_serviceProvider;
private static IConfiguration s_configuration;
private static DateTime? s_now;
public static DateTime Now
{
get
{
if (s_now == null)
{
return DateTime.UtcNow;
}
return s_now.Value;
}
set => s_now = value;
}
public static ServiceProvider ServiceProvider
{
get
{
if (s_serviceProvider == null)
{
s_serviceProvider = s_serviceProviderLazy.Value;
}
return s_serviceProvider;
}
set => s_serviceProvider = value;
}
public static IConfiguration Configuration
{
get
{
if (s_configuration == null)
{
s_configuration = s_configurationLazy.Value;
}
return s_configuration;
}
set => s_configuration = value;
}
static DefaultDbContextManager()
{
s_services = new ServiceCollection();
s_serviceProviderLazy =
new Lazy<ServiceProvider>(() =>
{
var services = s_services;
services.AddDbContextFactory<EmployeeDbContext>(ApplyConfigurePhysical);
//services.AddDbContextFactory<EmployeeDbContext>(ApplyConfigurePhysical);
// services.AddDbContextFactory<EmployeeDbContext>(ApplyConfigureMemory);
return services.BuildServiceProvider();
});
s_configurationLazy
= new Lazy<IConfiguration>(() =>
{
var configBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json");
return configBuilder.Build();
});
s_loggerFactory = LoggerFactory.Create(builder =>
{
builder
//.AddFilter("Microsoft", LogLevel.Warning)
//.AddFilter("System", LogLevel.Warning)
.AddFilter("Lab.DAL", LogLevel.Debug)
.AddConsole()
;
});
}
public static T GetInstance<T>()
{
return ServiceProvider.GetService<T>();
}
public static void SetMemoryDatabase<TContext>() where TContext : DbContext
{
var services = s_services;
services.Clear();
services.AddDbContextFactory<TContext>(ApplyConfigureMemory);
ServiceProvider = services.BuildServiceProvider();
}
public static void SetPhysicalDatabase<TContext>() where TContext : DbContext
{
var services = s_services;
services.Clear();
services.AddDbContextFactory<TContext>(ApplyConfigurePhysical);
ServiceProvider = services.BuildServiceProvider();
}
private static void ApplyConfigureMemory(IServiceProvider provider,
DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseInMemoryDatabase("Lab.DAL")
.UseLoggerFactory(s_loggerFactory)
;
}
private static void ApplyConfigurePhysical(IServiceProvider provider,
DbContextOptionsBuilder optionsBuilder)
{
var config = provider.GetService<IConfiguration>();
if (config == null)
{
config = Configuration;
}
var connectionString = config.GetConnectionString("DefaultConnection");
optionsBuilder.UseSqlServer(connectionString)
.UseLoggerFactory(s_loggerFactory)
;
}
}
完整代碼:
sample.dotblog/DefaultDbContextManager.cs at master · yaochangyu/sample.dotblog (github.com)
EmployeeRepository
幾個重點:
- 外部物件不需要知道資料表相依關係,通通交由 Repository 內部封裝實作
- 從靜態欄位取出目前時間 DefaultDbContextManager.Now
- 從 DI Container 取出 DbContextFactory<EmployeeDbContext>,呼叫 DbContextFactory.CreateDbContext() 建立 EmployeeDbContext 執行個體,用完就釋放
- 設定 EmployeeDbContext 屬性注入點,提供測試案例注入
public class EmployeeRepository : IEmployeeRepository
{
internal IDbContextFactory<EmployeeDbContext> DbContextFactory
{
get
{
if (this._dbContextFactory == null)
{
return DefaultDbContextManager.GetInstance<IDbContextFactory<EmployeeDbContext>>();
}
return this._dbContextFactory;
}
set => this._dbContextFactory = value;
}
internal EmployeeDbContext EmployeeDbContext
{
get
{
if (this._employeeDbContext == null)
{
return this.DbContextFactory.CreateDbContext();
}
return this._employeeDbContext;
}
set => this._employeeDbContext = value;
}
internal DateTime Now
{
get
{
if (this._now == null)
{
return DefaultDbContextManager.Now;
}
return this._now.Value;
}
set => this._now = value;
}
private IDbContextFactory<EmployeeDbContext> _dbContextFactory;
private EmployeeDbContext _employeeDbContext;
private DateTime? _now;
public async Task<int> InsertLogAsync(InsertOrderRequest request,
string accessId,
CancellationToken cancel = default)
{
await using var dbContext = this.EmployeeDbContext;
var toDbOrderHistory = new OrderHistory
{
Employee_Id = request.Employee_Id,
Product_Id = request.Product_Id,
Product_Name = request.Product_Id,
CreateAt = this.Now,
CreateBy = accessId,
Remark = request.Remark,
};
await dbContext.OrderHistories.AddAsync(toDbOrderHistory, cancel);
return await dbContext.SaveChangesAsync(cancel);
}
public async Task<int> NewAsync(NewRequest request,
string accessId,
CancellationToken cancel = default)
{
await using var dbContext = this.EmployeeDbContext;
var id = Guid.NewGuid();
var employeeToDb = new Employee
{
Id = id,
Name = request.Name,
Age = request.Age,
Remark = request.Remark,
CreateAt = this.Now,
CreateBy = accessId
};
var identityToDb = new Identity
{
Account = request.Account,
Password = request.Password,
Remark = request.Remark,
Employee = employeeToDb,
CreateAt = this.Now,
CreateBy = accessId
};
employeeToDb.Identity = identityToDb;
await dbContext.Employees.AddAsync(employeeToDb, cancel);
return await dbContext.SaveChangesAsync(cancel);
}
}
完整代碼:
sample.dotblog/EmployeeRepository.cs at master · yaochangyu/sample.dotblog (github.com)
sample.dotblog/NewRequest.cs at master · yaochangyu/sample.dotblog (github.com)
sample.dotblog/InsertOrderRequest.cs at master · yaochangyu/sample.dotblog (github.com)
EF Core Script
Migration Add
跑跑看是不是真的有套用
.NET Core CLI:
dotnet ef migrations add InitialCreate
執行命令的資料夾要有 DbContext
Visual Studio PowerShell:
Add-Migration InitialCreate
預設專案要有 DbContext
Update Database
接著就可以更新資料庫看看最後產出的資料表,不過,我目前不需要用這個指令(不會有資料結構異動),我想要透過測試專案幫我動態長出資料庫,塞資料、跑測試
Visual Studio PowerShell:
Update-Database
.NET Core CLI:
dotnet ef database update
有關 Migration 可以參考
[EF Core 3] 如何使用 Code First 的 Migration | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
建立測試專案
新增一個 .NET 5 / MsTest 測試專案,名為 Lab.DAL.TestProject,並在 Rider 的 Terminal 視窗輸入以下命令
dotnet add package Microsoft.Extensions.Hosting --version 5.0.0
dotnet add package Microsoft.EntityFrameworkCore --version 5.0.5
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 5.0.5
新增 appsettings.json,內容如下:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Lab.DAL.UnitTest;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}
操作真實的資料庫
案例第一次執行時重建測試資料庫,每次案例開始前後砍掉測試資料,整個案例通通都完成後,砍掉測試資料庫
完整代碼:
sample.dotblog/EmployeeRepositoryUnitTests.cs at master · yaochangyu/sample.dotblog (github.com)
[TestMethod]
public void 操作真實資料庫_預設EmployeeDbContext()
{
//arrange
DefaultDbContextManager.Now = new DateTime(1900, 1, 1);
DefaultDbContextManager.SetPhysicalDatabase<EmployeeDbContext>();
var builder = Host.CreateDefaultBuilder()
.ConfigureServices(services => { services.AddSingleton<EmployeeRepository>(); });
var host = builder.Build();
var repository = host.Services.GetService<EmployeeRepository>();
//act
var count = repository.NewAsync(new NewRequest
{
Account = "yao",
Password = "123456",
Name = "余小章",
Age = 18,
}, "TestUser").Result;
//assert
Assert.AreEqual(2, count);
using var db = new TestEmployeeDbContext(TestDbConnectionString);
var actual = db.Employees
.Include(p => p.Identity)
.AsNoTracking()
.FirstOrDefault();
Assert.AreEqual(actual.Name, "余小章");
Assert.AreEqual(actual.Age, 18);
Assert.AreEqual(actual.Identity.Account, "yao");
Assert.AreEqual(actual.Identity.Password, "123456");
}
操作記憶體
呼叫 DefaultDbContextManager.SetUseMemoryDatabase<EmployeeDbContext>() 注入EF In-Memory 設定
[TestMethod]
public void 操作記憶體()
{
DefaultDbContextManager.Now = new DateTime(1900, 1, 1);
DefaultDbContextManager.SetMemoryDatabase<EmployeeDbContext>();
var builder = Host.CreateDefaultBuilder()
.ConfigureServices(services => { services.AddSingleton<EmployeeRepository>(); });
var host = builder.Build();
var repository = host.Services.GetService<EmployeeRepository>();
var count = repository.NewAsync(new NewRequest(), "TestUser").Result;
Assert.AreEqual(2, count);
}
結論
- EF In-Memory 無法用來取代真實的資料庫,它並不會將物件操作語法翻成 SQL 。參考:
測試使用 EF Core 的程式碼 - EF Core | Microsoft Docs
- 它可以減少在商業邏輯(Biz)的單元測試建立假物件的成本,只要一行就變成在記憶體操作物件;副作用就是 Biz 需要知道 Repository 背後的依賴結構,這就違反了職責分離的原則。不過,如果 Biz 跟 Repository 都是一個人寫的,這也是一種選擇。
專案位置
sample.dotblog/ORM/EFCore/Lab.VirtualDb at master · yaochangyu/sample.dotblog (github.com)
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET