[EF Core 5][UnitTest]在 EF Core 使用 In-Memory 降低建立測試替身的成本

當我們要針對商業邏輯測試時,可能需要隔離 EFCore DbContext,搭配 Mock Framework 可以快速地建立測試替身假的 DbContext,自從 EF Core 的 In-Memory 出現之後,建立 DbContext 測試替身這件事,就變得輕鬆許多了

測試使用 EF Core 的程式碼 - EF Core | Microsoft Docs

物件依賴關係

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 完整代碼如下:

 

可以在 DbContext.OnConfiguring 重新配置 DbContext,但我不打算使用他

// 給 Migration CLI 使用
// 建構函數配置失敗才需要以下處理
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    ...
}

 

EmployeeContextFactory

新增 IDesignTimeDbContextFactory<EmployeeContext> 讓 .NET Core CLIPackage 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 或是自己用靜態欄位、方法封裝起來。

設定如下:

 

我選擇用 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

Image result for microsoft+mvp+logo