1. 程式人生 > >如何在 ASP.NET Core 測試中操縱時間?

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

中間件 fun .dll 服務端 thread cloc ren use 單元

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

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

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

在網上一番查閱過後,我發現 .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 的替代品滿足以下需求:

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

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

/// <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.Now

在測試代碼中,需要先手動調用 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,但是最近並沒有這麽多時間,所以先在這裏記著?(挖坑預定)。

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