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