C# TimeProviderで有効期限のテストを安定させる
有効期限を判定する処理でDateTimeOffset.UtcNowを直接使用すると、テスト中の時刻を固定できません。
実際に時間が過ぎるのを待つテストは遅く、境界付近で結果が不安定になります。
今回は.NETのTimeProviderを使用し、任意の現在時刻で期限判定をテストする方法を紹介します。
現在時刻を直接取得する場合の問題
テストしにくい例public bool IsExpired()
{
return DateTimeOffset.UtcNow >= ExpiresAt;
}このメソッドでは、テストから現在時刻を指定できません。
短い有効期限とTask.Delayを使う方法もありますが、実行環境の負荷によって失敗する可能性があります。
TimeProviderを受け取る
期限を判定するサービスへTimeProviderを渡します。
InvitationService.cspublic sealed record Invitation(Guid Id, DateTimeOffset ExpiresAt, DateTimeOffset? UsedAt);
public sealed class InvitationService(TimeProvider timeProvider)
{
public bool CanUse(Invitation invitation)
{
var now = timeProvider.GetUtcNow();
return invitation.UsedAt is null && now < invitation.ExpiresAt;
}
}この例では、有効期限と同じ時刻になった時点で使用不可としています。
<か<=かは仕様として決め、境界値をテストします。
本番ではTimeProvider.Systemを登録する
ASP.NET CoreのDIへ登録します。
Program.csbuilder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<InvitationService>();アプリケーションコードはTimeProvider.Systemを直接参照せず、コンストラクターから受け取ります。
テスト用の時刻を固定する
現在時刻を返すだけのテスト用クラスを作成します。
FixedTimeProvider.cspublic sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}以下はxUnitを使用したテスト例です。
InvitationServiceTests.cspublic sealed class InvitationServiceTests
{
private static readonly DateTimeOffset Now =
new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void 有効期限の直前は使用できる()
{
var service = new InvitationService(new FixedTimeProvider(Now));
var invitation = new Invitation(Guid.NewGuid(), Now.AddTicks(1), null);
Assert.True(service.CanUse(invitation));
}
[Fact]
public void 有効期限と同じ時刻は使用できない()
{
var service = new InvitationService(new FixedTimeProvider(Now));
var invitation = new Invitation(Guid.NewGuid(), Now, null);
Assert.False(service.CanUse(invitation));
}
[Fact]
public void 使用済みなら期限内でも使用できない()
{
var service = new InvitationService(new FixedTimeProvider(Now));
var invitation = new Invitation(Guid.NewGuid(), Now.AddHours(1), Now.AddMinutes(-1));
Assert.False(service.CanUse(invitation));
}
}待ち時間なしで、期限の直前と期限ちょうどを再現できます。
テスト中に時刻を進める
1つのテスト内で時刻を変えたい場合は、変更可能なProviderを作成します。
TestTimeProvider.cspublic sealed class TestTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public DateTimeOffset UtcNow { get; private set; } = utcNow;
public override DateTimeOffset GetUtcNow() => UtcNow;
public void Advance(TimeSpan duration)
{
if (duration < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(duration));
}
UtcNow = UtcNow.Add(duration);
}
}時刻を進めるテストvar timeProvider = new TestTimeProvider(Now);
var service = new InvitationService(timeProvider);
var invitation = new Invitation(Guid.NewGuid(), Now.AddMinutes(30), null);
Assert.True(service.CanUse(invitation));
timeProvider.Advance(TimeSpan.FromMinutes(30));
Assert.False(service.CanUse(invitation));UTCで比較する
有効期限はDateTimeOffsetを使用し、保存と比較をUTCにそろえると扱いやすくなります。
利用者のタイムゾーンへの変換は表示時に行います。
トークンや招待は、有効期限内でも使用済み、取消済み、対象ユーザーの変更などによって無効になる場合があります。
期限判定だけで利用可否を決めず、必要な状態を同じ処理で確認します。
TimeProviderを受け取ることで、現在時刻という外部依存を制御し、境界値を安定してテストできます。