使用 FakeTimeProvider 測試

📦 Microsoft.Extensions.TimeProvider.Testing NuGet 套件提供一個FakeTimeProvider類別,能對依賴時間的程式碼進行確定性測試。 這種假實作讓你能控制測試中的系統時間,確保結果可預測且可重複。

為什麼要使用 FakeTimeProvider

測試依賴當前時間或使用計時器的程式碼可能具有挑戰性:

  • 非確定性檢定:依賴即時時間的檢定可能產生不一致的結果。
  • 慢速測試:需要等待實際時間通過的測試會大幅拖慢測試執行速度。
  • 競賽條件:時間依賴邏輯可能會引入難以重現的競賽條件。
  • 邊緣案例:在特定時間(如午夜或月份邊界)測試基於時間的邏輯,對即時時間來說很困難。

FakeTimeProvider 透過以下方式來處理這些挑戰:

  • 提供對當前時間的完全掌控。
  • 讓你能瞬間推進時間,無需等待。
  • 實現時間基礎行為的確定性測試。
  • 讓測試邊緣情況和邊界條件變得容易。

開始使用

要開始使用 FakeTimeProvider,請安裝 Microsoft.Extensions.TimeProvider.Testing NuGet 套件。

dotnet add package Microsoft.Extensions.TimeProvider.Testing

欲了解更多資訊,請參閱 dotnet add package在 .NET 應用程式中管理套件相依性

基本用法

擴展FakeTimeProviderTimeProvider以提供可控的測試時間:

var fakeTimeProvider = new FakeTimeProvider();

// Get the current time (defaults to January 1, 2000, midnight UTC).
Console.WriteLine($"Start time: {fakeTimeProvider.GetUtcNow()}");

用特定時間初始化

你可以初始化 FakeTimeProvider 以設定特定的開始時間:

DateTimeOffset startTime = new(2025, 10, 20, 12, 0, 0, TimeSpan.Zero);
fakeTimeProvider = new FakeTimeProvider(startTime);

Console.WriteLine($"Started at: {fakeTimeProvider.GetUtcNow()}");

提前時間

Advance 方法將時間向前移動指定的時間段:

// Advance time by 30 minutes.
fakeTimeProvider.Advance(TimeSpan.FromMinutes(30));
Console.WriteLine($"After advancing 30 minutes: {fakeTimeProvider.GetUtcNow()}");

設定時區

為假時間供應商設定當地時區:

var timeZoneId = OperatingSystem.IsWindows() ? "Pacific Standard Time" : "America/Los_Angeles";
var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
fakeTimeProvider.SetLocalTimeZone(pacificTimeZone);

var localTime = fakeTimeProvider.GetLocalNow();
Console.WriteLine($"Local time: {localTime}");

測試延遲作業

FakeTimeProvider 對於涉及延遲的操作測試特別有用:

public class DelayedOperationTests
{
    [Fact]
    public async Task DelayedOperation_CompletesAfterDelay()
    {
        // Arrange
        var fakeTimeProvider = new FakeTimeProvider();
        var operation = new DelayedOperation(fakeTimeProvider);

        // Act
        Task task = operation.ExecuteAsync(TimeSpan.FromMinutes(5));

        // Assert - operation should not be complete yet
        Assert.False(task.IsCompleted);

        // Advance time by 5 minutes
        fakeTimeProvider.Advance(TimeSpan.FromMinutes(5));

        // Wait for the task to complete
        await task;

        // Operation should now be complete
        Assert.True(task.IsCompleted);
    }
}

public class DelayedOperation(TimeProvider timeProvider)
{
    public async Task ExecuteAsync(TimeSpan delay)
    {
        await Task.Delay(delay, timeProvider);
    }
}

測試週期性操作

使用計時器定期執行的測試操作:

public class PeriodicOperationTests
{
    [Fact]
    public void PeriodicOperation_ExecutesAtIntervals()
    {
        // Arrange
        var fakeTimeProvider = new FakeTimeProvider();
        var counter = new PeriodicCounter(fakeTimeProvider);
        counter.Start(TimeSpan.FromSeconds(10));

        // Act & Assert
        Assert.Equal(0, counter.Count);

        // Advance by 10 seconds
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(10));
        Assert.Equal(1, counter.Count);

        // Advance by 20 more seconds
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(20));
        Assert.Equal(3, counter.Count);

        // Clean up
        counter.Stop();
    }
}

public class PeriodicCounter(TimeProvider timeProvider)
{
    private ITimer? _timer;

    public int Count { get; private set; }

    public void Start(TimeSpan interval)
    {
        _timer = timeProvider.CreateTimer(
            callback: _ => Count++,
            state: null,
            dueTime: interval,
            period: interval);
    }

    public void Stop()
    {
        _timer?.Dispose();
    }
}

測試基於時間的商業邏輯

依賴特定時間或日期的商業邏輯測試:

public class SubscriptionTests
{
    [Fact]
    public void Subscription_ExpiresAfterOneYear()
    {
        // Arrange
        var fakeTimeProvider = new FakeTimeProvider();
        var startDate = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
        fakeTimeProvider.SetUtcNow(startDate);

        var subscription = new Subscription(fakeTimeProvider);
        subscription.Activate();

        // Assert - subscription is active
        Assert.True(subscription.IsActive);

        // Act - advance time by 11 months
        fakeTimeProvider.Advance(TimeSpan.FromDays(30 * 11));
        Assert.True(subscription.IsActive);

        // Advance time by 2 more months
        fakeTimeProvider.Advance(TimeSpan.FromDays(60));
        Assert.False(subscription.IsActive);
    }
}

public class Subscription(TimeProvider timeProvider)
{
    private DateTimeOffset _activationDate;

    public void Activate()
    {
        _activationDate = timeProvider.GetUtcNow();
    }

    public bool IsActive
    {
        get
        {
            DateTimeOffset currentTime = timeProvider.GetUtcNow();
            DateTimeOffset expirationDate = _activationDate.AddYears(1);
            return currentTime < expirationDate;
        }
    }
}

與依賴注入的整合

使用FakeTimeProvider以測試透過相依性注入註冊的服務:

public class CacheServiceTests
{
    [Fact]
    public void Cache_ExpiresAfterTimeout()
    {
        // Arrange
        var fakeTimeProvider = new FakeTimeProvider();

        var services = new ServiceCollection();
        services.AddSingleton<TimeProvider>(fakeTimeProvider);
        services.AddSingleton<CacheService>();

        ServiceProvider provider = services.BuildServiceProvider();
        CacheService cache = provider.GetRequiredService<CacheService>();

        // Act
        cache.Set("key", "value", TimeSpan.FromMinutes(10));

        // Assert - value is present
        Assert.True(cache.TryGet("key", out string? value));
        Assert.Equal("value", value);

        // Advance time beyond expiration
        fakeTimeProvider.Advance(TimeSpan.FromMinutes(11));

        // Value should be expired
        Assert.False(cache.TryGet("key", out _));
    }
}

public class CacheService(TimeProvider timeProvider)
{
    private readonly Dictionary<string, CacheEntry> _cache = [];

    public void Set(string key, string value, TimeSpan expiration)
    {
        DateTimeOffset expiresAt = timeProvider.GetUtcNow() + expiration;
        _cache[key] = new CacheEntry(value, expiresAt);
    }

    public bool TryGet(string key, out string? value)
    {
        if (_cache.TryGetValue(key, out CacheEntry? entry))
        {
            if (timeProvider.GetUtcNow() < entry.ExpiresAt)
            {
                value = entry.Value;
                return true;
            }

            // Entry expired, remove it
            _cache.Remove(key);
        }

        value = null;
        return false;
    }

    private record CacheEntry(string Value, DateTimeOffset ExpiresAt);
}

最佳做法

使用 FakeTimeProvider時請考慮以下最佳實務:

  • 注入 TimeProvider:永遠以依賴方式注入 TimeProvider ,而非直接使用 DateTimeDateTimeOffset 直接注入。 這讓你的程式碼變得可測試。
  • 使用 UTC 時間:在商業邏輯中調整 UTC 時間,只有在需要顯示時才轉成當地時間。
  • 測試邊緣案例:用來 FakeTimeProvider 測試像午夜、月份邊界、夏令時間轉換和閏年等邊緣案例。
  • 清理計時器:丟棄用CreateTimer創建的計時器,以避免測試中出現資源外洩。
  • 意識地提前時間:在測試中明確提前時間,讓測試行為清晰且可預測。
  • 避免混淆真實時間與假時間:不要在同一考試中混合TimeProvider.SystemFakeTimeProvider真實時間與假時間,因為這可能導致不可預測的行為。

另請參閱