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


この記事は「[Qiita - C# Advent Calendar 2023](https://qiita.com/advent-calendar/2023/csharplang)」に参加しています。

ブログでの公開はこちらの記事「[C# .NET8 TimeProvider の利用について](https://shikaku-sh.hatenablog.com/entry/c-sharp-time-abstraction-net8)」です。

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


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

- [TimeProvider](https://learn.microsoft.com/ja-jp/dotnet/core/whats-new/dotnet-8)

補足：`TimeProvider` は、主に**テストのためのもの**です。一例ですが [Issues](https://github.com/dotnet/runtime/issues/36617) に書いてあった内容は、以下のとおり。

> 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` クラスを使って現在時間を取得した場合と比較します。

```cs
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 かをチェックできます。

# 実装例

（現地時間の）正午（１２時）かどうかをチェックするサービスクラスを実装してみます。

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

`TimeProvider` を用意する箇所はどこかで必要だけれど、大体のシステムなら `TimeProvider.System` だけでもいい。これは仕様策定の際にも[コメントがあった](https://github.com/dotnet/runtime/issues/36617#issuecomment-1488795826)ようです：

> 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.


```cs
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` を利用しても同じようなロジックは可能です。これはテストの際に問題になるでしょうか。

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

		return now.Hour == 12;
	}
```


# テストに利用する

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

```cs
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 のようなこと）なので、（ここまでのコードだと、テストコード実行者が複数いて、複数の国に拠点がある場合）テストの実行環境によって成功したり失敗したりする恐れがあります。

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

```cs
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 を指定する必要があります。

- [List of Timezone IDs for use with FindTimeZoneById](https://stackoverflow.com/questions/7908343/list-of-timezone-ids-for-use-with-findtimezonebyid-in-c)

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

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

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

# ITimer インターフェース

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

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

以下のようなサンプルコードの提示が「[.NET8 の新機能](https://learn.microsoft.com/ja-jp/dotnet/core/whats-new/dotnet-8#time-abstraction)」にあるのですが、どういうことを説明しているのかよくわかりませんでした。

```cs
// 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 の場合はちょっとひと手間が必要になるように思います。

- [FakeTimeProvider](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs)

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

- [The TimeProvider abstraction in .NET 8](https://andrewlock.net/exploring-the-dotnet-8-preview-avoiding-flaky-tests-with-timeprovider-and-itimer/#the-timeprovider-abstraction-in-net-8)
- [FakeTimeProvider と FakeLogger を試す](https://aneuf.hatenablog.com/entry/2023/11/27/210000)

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

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

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

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

```cs
[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);
}
```

> まだこれらの機能は、自分のなかで熟していないので、触っていくなかで改訂があるかも。

# 参考

- [TimeProvider and ITimer: Writing Unit Tests with Time in .NET 8 Preview 4](https://www.infoq.com/articles/dotnet-unit-tests-time-timezone/)
- [TimeProvider - New Date & Time Abstractions in .NET 8!](https://www.youtube.com/watch?v=3vRUdc1GA4k)