使用 Microsoft.Bcl.TimeProvider 取代 DateTime 吧

我想很多人看到標題後都會直接想
「程式裡直接用 DateTime  難道錯了嗎?」
「DateTime  用得好好的,為什麼要改用 Microsoft.Bcl.TimeProvider ?」
「我又不必寫測試,我就是要用 DateTime!」

我也管不著各位想怎麼用、想怎麼寫程式,只是看到很多有關於交易處理的程式都是跟時間判斷有關,而且有著種種原因而沒有寫測試,就讓人覺得那些產品妥當嗎?
而且一遇到 DateTime 的處理,就會卡在不知道如何寫測試。

所以就來寫這一篇簡單介紹 Microsoft.Bcl.TimeProvider

直接在程式裡使用 DateTime

以下是一個簡單的程式,判斷當前的日期時間,看看當天是否為假日,如果不是假日則判斷是否超過下午三點半

public class TradeService : ITradeService
{
    private readonly IHolidayRepository _holidayRepository;

    /// <summary>
    /// Initializes a new instance of the <see cref="TradeService"/> class
    /// </summary>
    /// <param name="holidayRepository">The holiday repository</param>
    public TradeService(IHolidayRepository holidayRepository)
    {
        this._holidayRepository = holidayRepository;
    }

    /// <summary>
    /// 現在是否可以交易
    /// </summary>
    /// <returns>The bool</returns>
    public bool IsTradeNow()
    {
        var now = DateTime.Now;

        var holidays = this._holidayRepository.GetHolidays(now.Year, now.Month);

        if (holidays.Any(x => x.Date == now.Date))
        {
            return false;
        }
        
        var pm0330 = new TimeOnly(15, 30, 0);
        var currentTime = TimeOnly.FromDateTime(now);

        return currentTime < pm0330;
    }
}

可以看到上面的程式裡直接使用了 DateTime.Now  來取得當前的日期時間。

當然在沒有需要寫單元測試需求時,直接使用 DateTime.Now 看起來好像沒有問題,因為程式邏輯相當簡單,應該沒有測試的必要。不過實際工作上的專案程式碼有關於日期時間的判斷,一定要比上面的程式碼還要來得複雜許多,因為關係到金錢交易,所以就有必要對程式去寫單元測試,但是程式裡直接使用了 DateTime就讓這段程式變得不可測。

 

使用 SystemTime  靜態類別

在很久以前上 91 哥的單元測試課程時,有介紹到可以建立一個 SystemTime 靜態類別,用來取代 DateTime  的使用,而這個 System.Time 也曾經存在於我經手的各個專案裡

public static class SystemTime
{
    internal static Func<DateTime> SetCurrentTime = () => DateTime.Now;
    
    internal static Func<DateTime> SetToday = () => DateTime.Today;
    
    public static DateTime Now => SetCurrentTime();
    

    public static DateTime Today => SetToday();
}

上面 SystemTime 靜態類別的程式裡兩個公開的靜態方法 Now 與 Today,預設是會 回傳 DateTime.Now 和 DateTime.Today。另外還有定義了兩個 Func<DateTime>  回傳 DateTime  的委派,而且使用了 internal  修飾,這兩個委派就可以用在單元測試裡去設定測試案例裡當下情境所需要的日期時間。

那麼把 TradeService 改用 SystemTime 取代原本直接使用 DateTime 程式

public class TradeService : ITradeService
{
    private readonly IHolidayRepository _holidayRepository;

    /// <summary>
    /// Initializes a new instance of the <see cref="TradeService"/> class
    /// </summary>
    /// <param name="holidayRepository">The holiday repository</param>
    public TradeService(IHolidayRepository holidayRepository)
    {
        this._holidayRepository = holidayRepository;
    }

    /// <summary>
    /// 現在是否可以交易
    /// </summary>
    /// <returns>The bool</returns>
    public bool IsTradeNow()
    {
        var now = SystemTime.Now;

        var holidays = this._holidayRepository.GetHolidays(now.Year, now.Month);

        if (holidays.Any(x => x.Date == now.Date))
        {
            return false;
        }
        
        var pm0330 = new TimeOnly(15, 30, 0);
        var currentTime = TimeOnly.FromDateTime(now);

        return currentTime < pm0330;
    }
}

可能有人就會覺得,不就是將 DateTime 改成使用 SystemTime 而已,這有什麼了不起。

正因為這樣的改變就讓原本不可測試的程式碼變成是可測試的程式碼,就可以寫單元測試來確保程式碼的邏輯正確性,所以就來寫個單元測試吧。

public class TradeServiceTests
{
    private readonly IHolidayRepository _holidayRepository;

    private readonly TradeService _sut;

    public TradeServiceTests()
    {
        this._holidayRepository = Substitute.For<IHolidayRepository>();
        this._sut = new TradeService(this._holidayRepository);
    }

    private static DateTime[] Holidays
    {
        get
        {
            var holidays = new[]
            {
                new DateTime(2024, 9, 1, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 2, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 7, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 8, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 14, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 15, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 21, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 22, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 29, 0, 0, 0, DateTimeKind.Local)
            };
            return holidays;
        }
    }
    
    [Fact]
    public void IsTradeNow_取得的目前時間為假日_應回傳false()
    {
        // arrange
        SystemTime.SetCurrentTime = () => new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local);
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }

    [Fact]
    public void IsTradeNow_取得的目前時間不是假日_且時間沒有超過下午三點半_應回傳true()
    {
        // arrange
        SystemTime.SetCurrentTime = () => new DateTime(2024, 9, 24, 14, 0, 0, DateTimeKind.Local);
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeTrue();
    }
    
    [Fact]
    public void IsTradeNow_取得的目前時間不是假日_但時間已經超過下午三點半_應回傳false()
    {
        // arrange
        SystemTime.SetCurrentTime = () => new DateTime(2024, 9, 24, 16, 0, 0, DateTimeKind.Local);
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }
}

測試結果

當然這個 SystemTime  是很簡單,而且要使用的話也必須讓參與專案的成員都要知道改用 SystemTime  取代 DateTime。但也因為這個類別的實作很簡單,當遇到需要其他很多日期時間的處理時,就必須要去擴充 SystemTime 的內容,而且原本在 MSTest  使用都還很正常,但是在 xUnit 的測試執行時就出現了狀況(我忘記當初的測試錯誤狀況,大致上好像是執行單一測試案例時會執行成功,但是當執行多個都有使用到 SystemTime 的測試案例時就會出現測試執行錯誤的情況),以致於後來我另外參考網路上有人提供的 IDateTimeProvider  來取代 SystemTime。

相關文章

 

Microsoft.Bcl.TimeProvider

到了 2023 年 .NET 8  即將正式發布前,得知 Microsoft 即將發佈一個 Microsoft.Bcl.TimeProvider 的套件,雖然版本號一出來就是 8.0.0,但並不限定於只能在 .NET 8  的專案使用,可以相容使用在 .NET 8 與.NET Standard 2.0 以及 .NET Framework 4.6.2  的專案

https://www.nuget.org/packages/Microsoft.Bcl.TimeProvider/

runtime/src/libraries/Microsoft.Bcl.TimeProvider at main · dotnet/runtime · GitHub

https://learn.microsoft.com/zh-tw/dotnet/api/system.timeprovider

在專案裡安裝 Microsoft.Bcl.TimeProvider,並且改寫程式

建構式注入 TimeProvider,然後將原本使用 SystemTime.Now 或是直接使用 DateTime.Now  的地方改用 this._timeProvider.GetLocalNow().DateTime

public class TradeService : ITradeService
{
    private readonly TimeProvider _timeProvider;
    
    private readonly IHolidayRepository _holidayRepository;

    /// <summary>
    /// Initializes a new instance of the <see cref="TradeService"/> class
    /// </summary>
    /// <param name="timeProvider">The timeProvider</param>
    /// <param name="holidayRepository">The holidayRepository</param>
    public TradeService(TimeProvider timeProvider, IHolidayRepository holidayRepository)
    {
        this._timeProvider = timeProvider;
        this._holidayRepository = holidayRepository;
    }

    /// <summary>
    /// 現在是否可以交易
    /// </summary>
    /// <returns>The bool</returns>
    public bool IsTradeNow()
    {
        var now = this._timeProvider.GetLocalNow().DateTime;

        var holidays = this._holidayRepository.GetHolidays(now.Year, now.Month);

        if (holidays.Any(x => x.Date == now.Date))
        {
            return false;
        }
        
        var pm0330 = new TimeOnly(15, 30, 0);
        var currentTime = TimeOnly.FromDateTime(now);

        return currentTime < pm0330;
    }
}

這邊可以稍微看看 TimeProvider 的原始碼,其實 TimeProvider  是個抽象類別,

https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/TimeProvider.cs

記得要去做注入設定

接著就是改寫單元測試的部分。

 

Microsoft.Extensions.TimeProvider.Testing

https://www.nuget.org/packages/Microsoft.Extensions.TimeProvider.Testing

https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md

https://learn.microsoft.com/zh-tw/dotnet/api/microsoft.extensions.time.testing.faketimeprovider

改寫後的測試程式如下

public class TradeServiceTests
{
    private readonly FakeTimeProvider _fakeTimeProvider;
    
    private readonly IHolidayRepository _holidayRepository;

    private readonly TradeService _sut;

    public TradeServiceTests()
    {
        this._fakeTimeProvider = new FakeTimeProvider();
        this._holidayRepository = Substitute.For<IHolidayRepository>();
        this._sut = new TradeService(this._fakeTimeProvider, this._holidayRepository);
    }

    private static DateTime[] Holidays
    {
        get
        {
            var holidays = new[]
            {
                new DateTime(2024, 9, 1, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 2, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 7, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 8, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 14, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 15, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 21, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 22, 0, 0, 0, DateTimeKind.Local),
                new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local), new DateTime(2024, 9, 29, 0, 0, 0, DateTimeKind.Local)
            };
            return holidays;
        }
    }
    
    [Fact]
    public void IsTradeNow_取得的目前時間為假日_應回傳false()
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local);
        this._fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        this._fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }

    [Fact]
    public void IsTradeNow_取得的目前時間不是假日_且時間沒有超過下午三點半_應回傳true()
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 24, 14, 0, 0, DateTimeKind.Local);
        this._fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        this._fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeTrue();
    }
    
    [Fact]
    public void IsTradeNow_取得的目前時間不是假日_但時間已經超過下午三點半_應回傳false()
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 24, 16, 0, 0, DateTimeKind.Local);
        this._fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        this._fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }
}

在測試類別裡要使用 FakeTimeProvider,因為這個 FakeTimeProvider 是繼承抽象類別 TimeProvider

不過只有 SetUtcNow  這個方法可以設定日期時間,所以要符合本地時間就要做點設定,不然測試當下都會是 UTC  時間

也可以使用 TimeZoneInfo.FindSystemTimeZoneById("id")  直接指定 TimeZone Id

測試當下 TimeZone 就會是指定的時區

 

無論你是不是有寫單元測試的需求或要求,可以的話就改用 Microsoft.Bcl.TimeProvider 取代 DateTime 。 

 

相關文章

以上