Contents

TimeProvider - .Net 測試程式時間方法

在 .NET 專案中直接呼叫 DateTime.NowDateTimeOffset.Now 很直覺,但會讓單元測試難以驗證「時間相關」邏輯。本文整理 .NET 內建的 TimeProvider 與幾種常見做法,包含 DI 注入、自訂時區、以及傳統靜態 DateTimeProvider,協助你把時間抽象化、好測又好維護。

為什麼需要 TimeProvider
過去我也常直接用 DateTime.Now,直到開始寫測試才發現時間很難「固定」。.NET 8 起微軟提供 TimeProvider 抽象層,能透過依賴注入傳入系統時間或假的時間來源(如 FakeTimeProvider),讓測試可以完全掌控時間。

官方定義與基本用法

1
2
3
4
5
6
Console.WriteLine($"Local: {TimeProvider.System.GetLocalNow()}");
Console.WriteLine($"Utc:   {TimeProvider.System.GetUtcNow()}");
/* 輸出類似:
 * Local: 12/5/2024 10:41:14 AM -08:00
 * Utc:   12/5/2024 6:41:14 PM +00:00
*/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
long stampStart = TimeProvider.System.GetTimestamp();
Console.WriteLine($"Starting timestamp: {stampStart}");

long stampEnd = TimeProvider.System.GetTimestamp();
Console.WriteLine($"Ending timestamp:   {stampEnd}");

Console.WriteLine($"Elapsed time: {TimeProvider.System.GetElapsedTime(stampStart, stampEnd)}");
Console.WriteLine($"Nanoseconds: {TimeProvider.System.GetElapsedTime(stampStart, stampEnd).TotalNanoseconds}"); 

/* 輸出類似:
 * Starting timestamp: 55185546133
 * Ending timestamp:   55185549929
 * Elapsed time: 00:00:00.0003796
 * Nanoseconds: 379600
*/
Stopwatch 與 TimeProvider

在引入 TimeProvider 之前,若要高解析度計時,常用 System.Diagnostics.Stopwatch

1
2
3
4
5
6
7
using System.Diagnostics;

Stopwatch stopwatch = Stopwatch.StartNew();
// 執行某些操作
stopwatch.Stop();
Console.WriteLine($"Elapsed: {stopwatch.Elapsed}");
Console.WriteLine($"Elapsed ms: {stopwatch.ElapsedMilliseconds}");

TimeProvider.GetTimestamp() 內部同樣倚賴高解析度計時器,差別是它具備抽象化與可測試性。

透過 DI 注入 TimeProvider

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class MyService
{
    private readonly TimeProvider _timeProvider;

    public MyService(TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
    }

    public void DoSomething()
    {
        Console.WriteLine($"Local: {_timeProvider.GetLocalNow()}");
        Console.WriteLine($"Utc:   {_timeProvider.GetUtcNow()}");
    }
}
1
2
3
4
5
6
// 一般程式使用方式
var myService = new MyService(TimeProvider.System);
myService.DoSomething();

// 在 Program.cs 透過相依性注入註冊
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);

自訂時區 TimeProvider(方法 1)

1
2
3
4
5
6
7
8
var moonLandingTimeProviderPST = new MoonLandingTimeProviderPST();

moonLandingTimeProviderPST.GetLocalNow();

public class MoonLandingTimeProviderPST : TimeProvider
{
    public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.FindSystemTimeZoneById("PST");
}

參考(LINQPad 分享):https://share.linqpad.net/r38r9ivo.linq

自訂時區 TimeProvider(方法 2)

備註
其實方法 1 就能滿足多數需求,以下為更彈性的作法,適合需要由設定檔指定時區時。

情境:主機時區是日本或 UTC,但業務邏輯需要台灣時區;不能動作業系統時區時,可自訂 TimeProvider

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var a = Options.Create(new AppSettings("EST"));
var customTimeProvider  = new CustomTimeProvider(a);

customTimeProvider.GetLocalNow();

public class CustomTimeProvider : TimeProvider
{
    public CustomTimeProvider(IOptions<AppSettings> settings)
    {
        string timeZoneId = settings.Value.TimeZoneId;
        if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out TimeZoneInfo info))
        {
            LocalTimeZone = info;
        }
        else
        {
            LocalTimeZone = TimeProvider.System.LocalTimeZone;
        }
    }

    public override TimeZoneInfo LocalTimeZone { get; }
}

public record AppSettings(string TimeZoneId);

參考(LINQPad 分享):https://share.linqpad.net/xplcfbkh.linq

使用 FakeTimeProvider 撰寫測試(推薦)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 需要套件:Microsoft.Extensions.Time.Testing
using Microsoft.Extensions.Time.Testing;

[Fact]
public void Should_Expire_Order_After_Timeout()
{
    var fake = new FakeTimeProvider(new DateTimeOffset(2024, 12, 1, 0, 0, 0, TimeSpan.Zero));
    var service = new OrderService(fake); // 你的服務內透過 _timeProvider 取得時間

    service.Create("A-001");

    fake.Advance(TimeSpan.FromMinutes(31)); // 推進時間 31 分鐘

    Assert.True(service.IsExpired("A-001"));
}

靜態 DateTimeProvider(傳統做法)

強者同事寫的靜態 DateTimeProvider,快取台灣時區,並提供常用轉換與建立函式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using System.Runtime.CompilerServices;

// 允許測試專案存取內部成員
[assembly: InternalsVisibleTo("TestInfrastructure")]
[assembly: InternalsVisibleTo("Helper.Tests")]
[assembly: InternalsVisibleTo("Project.Tests")]
[assembly: InternalsVisibleTo("Project.Hangfire.Tests")]
[assembly: InternalsVisibleTo("Application.Tests")]

namespace Helper;

public static class DateTimeProvider
{
    // 快取台灣時區
    private static readonly TimeZoneInfo _taiwanTz =
        TimeZoneInfo.FindSystemTimeZoneById("Taipei Standard Time");

    /// <summary>現在 (Taiwan 時區 DateTime)</summary>
    public static Func<DateTime> Now { get; internal set; } =
        () => TimeZoneInfo.ConvertTime(DateTime.UtcNow, _taiwanTz);

    /// <summary>現在 (Taiwan 時區 DateTimeOffset)</summary>
    public static Func<DateTimeOffset> NowOffset { get; internal set; } =
        () => TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _taiwanTz);

    /// <summary>建立 指定日期 (Taiwan 時區)</summary>
    public static DateTime NewDate(int year, int month, int day) =>
        new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc)
            .ToTaiwanTime();

    /// <summary>建立 指定日期時間 (Taiwan 時區)</summary>
    public static DateTime NewDate(int year, int month, int day, int hour, int minute, int second) =>
        new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc)
            .ToTaiwanTime();

    /// <summary>建立 DateTimeOffset (日期)</summary>
    public static DateTimeOffset NewOffset(int year, int month, int day) =>
        new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero)
            .ToTaiwanOffset();

    /// <summary>建立 DateTimeOffset (日期時間)</summary>
    public static DateTimeOffset NewOffset(int year, int month, int day, int hour, int minute, int second) =>
        new DateTimeOffset(year, month, day, hour, minute, second, TimeSpan.Zero)
            .ToTaiwanOffset();

    /// <summary>取得指定年份月份最後一天</summary>
    public static int GetLastDayOfMonth(int year, int month) =>
        DateTime.DaysInMonth(year, month);

    /// <summary>台灣時區</summary>
    public static TimeZoneInfo TaiwanTimeZone => _taiwanTz;

    // 擴充方法
    public static DateTime ToTaiwanTime(this DateTime dt) =>
        TimeZoneInfo.ConvertTime(dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Utc), _taiwanTz);

    public static DateTimeOffset ToTaiwanOffset(this DateTimeOffset dto) =>
        TimeZoneInfo.ConvertTime(dto, _taiwanTz);
}

其他參考文章

彩蛋

資料抓出來還是得自己轉換

1
2
3
4
5
6
DateTimeOffset originalTime = new DateTimeOffset(2025, 9, 22, 14, 0, 0, TimeSpan.FromHours(-4)); // 假設是美東時間
TimeZoneInfo taipeiZone = TimeZoneInfo.FindSystemTimeZoneById("Taipei Standard Time");
DateTimeOffset taipeiTime = TimeZoneInfo.ConvertTime(originalTime, taipeiZone);

Console.WriteLine($"原始時間: {originalTime}");
Console.WriteLine($"台北時間: {taipeiTime}");

心智圖

mindmap root((TimeProvider in .NET)) Why 測試可控性 移除 DateTime.Now 相依 Built-in APIs GetLocalNow GetUtcNow GetTimestamp GetElapsedTime DI 透過相依性注入 測試傳入 FakeTimeProvider Custom TimeZone 覆寫 LocalTimeZone 設定檔指定 TimeZoneId FakeTimeProvider 推進時間 Advance 冷凍/模擬現在時間 Legacy Static Provider 台灣時區快取 建立 DateTime/DateTimeOffset Tips Stopwatch 對照 System.Text.Json 時區議題