如何在 ASP.NET Core 測試中操縱時間?

有時候,咱們會遇到一些跟系統當前時間相關的需求,例如:git

  • 只有開學季才容許錄入學生信息
  • 只有到了晚上或者週六才容許備份博客
  • 註冊滿 3 天的用戶才容許進行一些操做
  • 某用戶在 24 小時內被禁止發言

很顯然,要實現這些功能的代碼多多少少要用到 DateTime.Now 這個靜態屬性,然而要使用單元測試或者集成測試對上述需求進行驗證,每每須要採用一些曲線救國的方法甚至是直接跳過這些測試,這是由於在 .Net 中,DateTime.Now 一般難以被 Mock 。這時候我就要誇一誇 Angular 的測試工具了,較完美的提供了 Date 對象的 Mock 方法,因此在編寫測試代碼的時候能夠很容易的操縱 「當前時間」。github

在網上一番查閱事後,我發現 .Net FrameWork 中曾經是有這樣的工具的,不只僅是 Mock DateTime.Now,其餘的不少來自於 mscorlib.dll 的方法、屬性也能夠被 Mock。這類工具根據工做原理大體分爲三類,第一類是提供了一個生成假 mscorlib.dll 的方法,而後再把生成出來的假的 dll 添加到測試項目中,第二類則是在運行時建立一個獨立的 AppDomain,而後在這個 AppDomain 中加載程序集的時候臨時生成一個內存中的假程序集替換進去,還有一種則是直接在運行時修改目標函數/屬性的引用地址。這三種解決方案中,我我的更傾向於第二種 —— 更加靈活,並且不會改變現有流程。不過,這些搜索到的結果基本上都是面向 .Net Framework 開發的,能支持 .Net Core 並且不收費的工具,我如今還沒找到。如今我在關注的是 Smocks 這個項目,也嘗試過把他遷移到 .Net Core 上,結果由於 netstandard 中缺乏必要 API 而了結,看微軟的開發進度,他們估計要到 .Net Core 3.0 纔會補上這些 API,這個項目能等,但我手頭上的項目等不起啊,沒辦法,只能先拙劣的替換DateTime.Now 來實現相似的功能了。多線程

用什麼來代替 DateTime.Now

一個合格的 DateTime.Now 的替代品知足如下需求:app

  1. 因爲測試用例每每是多線程並行隨機執行,因此替代品在線程間須要相互隔離
  2. 在集成測試中,ASP.NET Core 服務端代碼與測試代碼並非運行在同一個線程中的,這時候,替代品須要可以在線程中共享
  3. 可以隨時的設置當前時間
  4. 在生產環境中,必須與 DateTime.Now 功能一致
  5. 替代品的簽名要與 DateTime.Now 一致

在爆棧網上的 這個答案的基礎上,我本身改造了一個在 ASP.NET Core 集成測試中可用的 SystemClock 類:async

/// <summary>
/// Provides access to system time while allowing it to be set to a fixed <see cref="DateTime"/> value.
/// </summary>
/// <remarks>
/// This class is thread safe.
/// </remarks>
public static class SystemClock
{
    private static readonly Func<DateTime> Default = () => DateTime.Now;

    public static ThreadLocal<string> ClockId = new ThreadLocal<string>(() => "prod");

    public static Dictionary<string, Func<DateTime>> ClocksMap = new Dictionary<string, Func<DateTime>>()
    {
        ["prod"] = Default
    };
    private static DateTime GetTime()
    {
        var fn = ClocksMap[ClockId.Value] ?? Default;
        return fn();
    }

    /// <inheritdoc cref="DateTime.Today"/>
    public static DateTime Today => GetTime().Date;

    /// <inheritdoc cref="DateTime.Now"/>
    public static DateTime Now => GetTime();

    /// <inheritdoc cref="DateTime.UtcNow"/>
    public static DateTime UtcNow => GetTime().ToUniversalTime();

    /// <summary>
    /// Sets a fixed (deterministic) time for the current thread to return by <see cref="DateTime"/>.
    /// </summary>
    public static void Set(DateTime time)
    {
        if (time.Kind != DateTimeKind.Local)
            time = time.ToLocalTime();

        ClocksMap[ClockId.Value] = () => time;
    }

    /// <summary>
    /// Initialize clock with an id, so that you can share the clock across threads.
    /// </summary>
    /// <param name="clockId"></param>
    public static void Init(string clockId)
    {
        ClockId.Value = clockId;
        if (ClocksMap.ContainsKey(clockId) == false)
        {
            ClocksMap[clockId] = Default;
        }
    }

    /// <summary>
    /// Resets <see cref="SystemClock"/> to return the current <see cref="DateTime.Now"/>.
    /// </summary>
    public static void Reset()
    {
        ClocksMap[ClockId.Value] = Default;
    }

}

在產品代碼中,須要手動的把全部的 DateTime.Now 替換成 SystemClock.Nowide

在測試代碼中,須要先手動調用 SystemClock.Init(clockId) 來進行初始化,它會把傳入的 clockId 存儲爲一個當前線程中的一個靜態變量,同時爲這個 Id 設置一個單獨的返回 DateTime 的委託。在使用 SystemClock.Now 的時候,它會尋找當前線程中 ClockId 對應的委託並返回執行結果。這樣,只要多個線程中的 SystemClock.Init 是經過一樣的 clockId 調用的,咱們就能夠在這些線程的任意一箇中共享或者設置 SystemClock.Now 的返回結果,而不一樣的線程中,若是 ClockId,那麼他們的 SystemClock.Now 相互不受影響。函數

舉個例子,假設有一個 TestStartup.cs ,爲了可以讓咱們在測試用例代碼執行的線程中修改 Controller 執行線程中 SystemClock.Now 的執行結果,首先須要設置一下 Configure 方法:工具

public override void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    // TestStartup.Configure 會在測試線程中調用
    var clockId = Guid.NewGuid().ToString();
    SystemClock.Init(clockId);
    app.Use(async (context, next) =>
    {
        // 中間件的執行線程與測試線程不一樣但與 Controller、Service 的執行線程相同
        SystemClock.Init(clockId);
        await next();
    });
}

因爲每次處理咱們請求的線程可能並非同一個,因此我就在第一個中間件中添加了初始化 SystemClock 的代碼。在測試用例中,咱們就能夠操縱時間了:單元測試

public async void SomeTest()
{
    var now = new DateTime(2022,1,1);
    SystemClock.Set(now);
    // 註冊用戶
    // Assert: 用戶還不能夠發言
    var threeDaysAfter = now.AddDays(3);
    SystemClock.Set(threeDaysAfter);
    // Assert: 用戶能夠發言了

一個想法

因爲手動替換 DateTime.Now 對現有代碼改動很大,因此上面提出的只是一個簡單的臨時應對方案。但要解決這個問題其實也不是很難,能夠嘗試在 dotnet build 以後把生成出來的全部 dll 經過工具處理一遍,在編譯的結果中替換 DateTime.Now,可是最近並無這麼多時間,因此先在這裏記着⛏(挖坑預約)。測試

相關文章
相關標籤/搜索