sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# TimeProvider の利用について (.NET8)

この記事は「Qiita - C# Advent Calendar 2023」に参加しています。

C# には、時間を表現するクラスに DateTimeDateTimeOffset があります。.NET 8 から TimeProvider クラスが新しく用意されました。

TimeProvider クラスは .NET8 の新機能のひとつ「時間抽象化 (Time abstraction)」として紹介されています。時間の抽象化は、コードテストの課題に新しいメリットがあります。その内容を記録した記事です。

補足:TimeProvider は、主にテストのためのものです。一例ですが Issues に書いてあった内容は、以下のとおり。

The abstraction does one thing and one thing only: it makes it possible to test the flow of time. That's it.

DateTime.Now との違い

TimeProvider クラスは、現在時間を取得することができます。従来の DateTime クラスを使って現在時間を取得した場合と比較します。

public void Run(string[] args)
{
    var now = TimeProvider.System.GetLocalNow();
    var utcNow = TimeProvider.System.GetUtcNow();

    Console.WriteLine(now);
    Console.WriteLine(utcNow);

    var now2 = DateTime.Now;
    var utcNow2 = DateTimeOffset.UtcNow;

    Console.WriteLine(now2);
    Console.WriteLine(utcNow2);
}
2023/12/08 15:53:10 +09:00
2023/12/08 6:53:10 +00:00
2023/12/08 15:53:10
2023/12/08 6:53:10 +00:00

これだけだと違いはなさそう。テストコードから DateTime.Now を呼び出すケースと TimeProvider.System.GetLocalNow() を呼び出すケースだと、どのような違いが出るのか、そこがこの先の読み解きポイントになります。

ちなみに DateTimeProvider から取得した変数 now utcNow はどちらも DateTimeOffset 型です。なので GetLocalNow() でも +09:00 が出力されていますね。

補足:DateTime 型は Kind プロパティで UTC か Local かをチェックできます。

実装例

(現地時間の)正午(12時)かどうかをチェックするサービスクラスを実装してみます。(これはビジネス等の用途にあわせる部分なので、どんなものでも)

時間のチェックを目的としたクラスの中で、時間を用意して結果を返却するようにします。

もちろん TimeProvider を用意する箇所はどこかで必要だけれど、大体のシステムなら TimeProvider.System だけでもいい。これは仕様策定の際にもコメントがあったようです:

At the end of the day, we expect almost no one will use anything other than TimeProvider.System in production usage. Unlike many abstractions then, this one is special: it exists purely for testability.

public class TimeService
{
    private readonly TimeProvider _TimeProvider;

    public TimeService(TimeProvider timeProvider) => _TimeProvider = timeProvider;

    public bool IsNoon()
    {
        var now = _TimeProvider.GetLocalNow();

        return now.Hour == 12;
    }
}

DateTime を利用しても同じロジックは可能です。これはテストの際にどうなるでしょうか。

   public bool IsNoon()
    {
        var now = DateTime.Now;

        return now.Hour == 12;
    }

テストに利用する

今回の一番重要な部分です。

public class Tests
{
    public class NoonTimeProvider : TimeProvider
    {
        private readonly TimeSpan JST = new TimeSpan(9, 0, 0);

        public override DateTimeOffset GetUtcNow()
        {
            return new DateTimeOffset(2023, 12, 1, 3, 0, 0, JST);
        }
    }

    [SetUp]
    public void Setup()
    {
    }

    [Test]
    public void Test()
    {
        var testTimeProvider = new NoonTimeProvider();

        var testService = new TimeService(testTimeProvider);
        var isNoon = testService.IsNoon();

        Assert.IsTrue(isNoon);
    }
}

GetUtcNow を override することで、テスト用の時間を用意しています。テストのために抽象化するクラスの部分は、すこし嵩張るかもしれません。

もしも TimeService クラスで時間を取得する際に DateTime.Now を利用していたらどうでしょうか。上のコードの例では TimeProvider クラスを継承して GetUtcNow メソッドを override しましたが、DateTime クラスは seal されているので継承が難しく、また Now プロパティを override するというのも難しく、仮にどうにかできたとしてもモヤモヤする実装になりそう。

基本的には DateTimeDateTimeOffset は、(本来)ある時間を表現したデータ型のようです。DateTimeDateTimeOffset から新たに現在時間を問い合わせる Now といった機能は便利ですが、拡張しづらい。

テストコードを書く際に難があったので、今回の TimeProvider が出てきました。なお、注意点として TimeProvider クラスの GetLocalNow は override できません。なので、ここまでのテストコードは(次で説明をする)都合の悪い部分が残っています。もう一歩拡張します。

継続的インテグレーションの考慮

IsNoon メソッドは、メソッドの中で GetLocalNow を利用しているため、UTC 現地時間の補正が加/減算されます。(日本だと +9:00 のようなこと)なので、(ここまでのコードだと、テストコード実行者が複数いて、複数の国に拠点がある場合)テストの実行環境によって成功したり失敗したりする恐れがあります。

これに対応したコードは次のようになります。

public class Tests
{
    public class NoonTimeProvider : TimeProvider
    {
        public override DateTimeOffset GetUtcNow()
        {
            return new DateTimeOffset(2023, 12, 1, 3, 0, 0, TimeSpan.Zero);
        }

        public override TimeZoneInfo LocalTimeZone => 
            TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
    }

    [SetUp]
    public void Setup()
    {
    }

    [Test]
    public void Test()
    {
        var testTimeProvider = new NoonTimeProvider();

        var testService = new TimeService(testTimeProvider);
        var isNoon = testService.IsNoon();

        Assert.IsTrue(isNoon);
    }
}

TimeProviderタイムゾーンを変更し、テストコード中は固定することができます。FindSystemTimeZoneByIdタイムゾーンの ID を指定する必要があります。

上の例だと、テストコードを東京の UTC 時間に設定しています。これでテスト実行環境差を抽象化することができました。「時間の抽象化」という TimeProvider クラスの機能を活用できた、という話なんだと思います。

ここまでの話から DateTimeDateTimeOffset で「時間の抽象化」をやろうとすると、よりテスト用のモックが肥大化することが予想できると思います。(ローカル時間だけならいいのかもしれない)

今後は(時間に関しては).NET8 の TimeProvider クラスを活用したテスト用のモックを作るようにしたほうが良いケースが多そうです。

時間の扱いは細かい話が多く、例えばサマータイムの扱いには注意が必要です。OS でサマータイムを処理している場合もありますが、企業による取り組みのような機械的に処理されないケースもあるし、人の都合にあわせて改定されることもあります。(こうした際も TimeProvider クラスは、有効に機能するはず)

ITimer インターフェース

ITimer インターフェースは次のとおり。

public interface ITimer : IDisposable, IAsyncDisposable
{
    bool Change(TimeSpan dueTime, TimeSpan period);
}

以下のようなサンプルコードの提示が「.NET8 の新機能」にあるのですが、どういうことを説明しているのかよくわかりませんでした。

// Create a timer using a time provider.
ITimer timer = timeProvider.CreateTimer(
    callBack, state, delay, Timeout.InfiniteTimeSpan);

// Measure a period using the system time provider.
long providerTimestamp1 = TimeProvider.System.GetTimestamp();
long providerTimestamp2 = TimeProvider.System.GetTimestamp();

var period = GetElapsedTime(providerTimestamp1, providerTimestamp2);

ITimer を利用している例は、現在のネット上だと FakeTimeProvider を利用したものが見つかります。NUnit の場合はちょっとひと手間が必要になるように思います。

サンプルは以下のとおり。

CreateTimer は dueTime の後に period の間隔で state を引数としたコールバックを発生させる。一度だけ発生させる場合は Timeout.InfiniteTimeSpan(周期的なコールバック無効)を設定して dueTime を実行時間にする。

テストの内容が1時間とか2時間といった長時間の経過を見ないとわからない場合、時間を抽象化することで時間を進めてテストを実装します。現実に時間の経過を待つのがめんどくさい、現実的ではない。そういった際に FakeTimeProviderAdvance というメソッドで時間を進めることができます。

timeProvider.Advance(TimeSpan.FromHours(1));

イメージとしてはこんな感じだと思います。

[Test]
public void TestITimer()
{
    var testTimeProvider = new FakeTimeProvider();
    var result = false;

    var itimer = testTimeProvider.CreateTimer(
        _ =>
        {
            // state は null なので _ にしている
            // 時間経過後にすること
            result = true;
        }, 
     state: null, 
     dueTime: TimeSpan.FromSeconds(1000),
     period: Timeout.InfiniteTimeSpan);

    testTimeProvider.Advance(TimeSpan.FromSeconds(1000));

    Assert.True(result);
}

参考