zukucode
主にWEB関連の情報を技術メモとして発信しています。

C# TimeProviderで有効期限のテストを安定させる

有効期限を判定する処理でDateTimeOffset.UtcNowを直接使用すると、テスト中の時刻を固定できません。

実際に時間が過ぎるのを待つテストは遅く、境界付近で結果が不安定になります。

今回は.NETのTimeProviderを使用し、任意の現在時刻で期限判定をテストする方法を紹介します。

現在時刻を直接取得する場合の問題

テストしにくい例
public bool IsExpired()
{
    return DateTimeOffset.UtcNow >= ExpiresAt;
}

このメソッドでは、テストから現在時刻を指定できません。

短い有効期限とTask.Delayを使う方法もありますが、実行環境の負荷によって失敗する可能性があります。

TimeProviderを受け取る

期限を判定するサービスへTimeProviderを渡します。

InvitationService.cs
public 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.cs
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<InvitationService>();

アプリケーションコードはTimeProvider.Systemを直接参照せず、コンストラクターから受け取ります。

テスト用の時刻を固定する

現在時刻を返すだけのテスト用クラスを作成します。

FixedTimeProvider.cs
public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => utcNow;
}

以下はxUnitを使用したテスト例です。

InvitationServiceTests.cs
public 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.cs
public 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を受け取ることで、現在時刻という外部依存を制御し、境界値を安定してテストできます。


関連記事