この記事は「Qiita - C# Advent Calendar 2023」に参加しています。
ブログでの公開はこちらの記事「C# .NET8 TimeProvider の利用について」です。
C# には、時間を表現するクラスに DateTime
と DateTimeOffset
があります。.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 するというのも難しく、仮にどうにかできたとしてもモヤモヤする実装になりそう。
基本的には DateTime
や DateTimeOffset
は、(本来)ある時間を表現したデータ型のようです。DateTime
や DateTimeOffset
から新たに現在時間を問い合わせる 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
クラスの機能を活用できた、という話なんだと思います。
ここまでの話から DateTime
や DateTimeOffset
で「時間の抽象化」をやろうとすると、よりテスト用のモックが肥大化することが予想できると思います。(ローカル時間だけならいいのかもしれない)
今後は(時間に関しては).NET8 の 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時間といった長時間の経過を見ないとわからない場合、時間を抽象化することで時間を進めてテストを実装します。現実に時間の経過を待つのがめんどくさい、現実的ではない。そういった際に FakeTimeProvider
は Advance
というメソッドで時間を進めることができます。
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);
}
まだこれらの機能は、自分のなかで熟していないので、触っていくなかで改訂があるかも。