如何在 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,可是最近并无这么多时间,因此先在这里记着⛏(挖坑预约)。测试

相关文章
相关标签/搜索