メインコンテンツまでスキップ

テスト

Valkarn Tasksは、非同期コード向けに高速で決定論的なユニットテストを書くための専用テストインフラを同梱しています — リアルタイマーなし、フレーム待機なし、コアテストスイートにUnity Editorも不要です。


概要

テストサポートはTesting/アセンブリ(UnaPartidaMas.Valkarn.Tasks.Testing)にあり、InternalsVisibleToを通じてランタイムから可視です。2つのパブリック型を公開します:

目的
ValkarnTaskTestHelperテストセッション向けにValkarn Tasksランタイムを初期化・終了する
TestClock決定論的な時間とフレームプロバイダー;テスト中にUnityのTimeProviderを置き換える

ValkarnTaskTestHelper

ValkarnTaskTestHelperUnaPartidaMas.Valkarn.Tasks.Testing名前空間の静的ユーティリティクラスです。

動作内容

Setup()は3つのことを実行します:

  1. TestClockを作成してTimeProvider.Currentとしてインストールし、リアルUnity時間プロバイダーを置き換えます。
  2. PlayerLoopHelper.InitializeForTest()を呼び出し、16すべてのPlayerLoopタイミングのContinuationQueuePlayerLoopRunner配列を確保してランタイムを初期化済みとしてマークします。
  3. テストが時間を制御できるようにTestClockを返します。

Teardown()はセットアップを逆転します:

  1. 新鮮な何もしないTestClockTimeProvider.Currentとしてインストールして、テストフィクスチャ間で古い時間が漏れるのを防ぎます。
  2. PlayerLoopHelper.ShutdownForTest()を呼び出し、キューとランナーを解体します。

使用パターン

using NUnit.Framework;
using UnaPartidaMas.Valkarn.Tasks;
using UnaPartidaMas.Valkarn.Tasks.Testing;

[TestFixture]
public class MyAsyncTests
{
TestClock clock;

[SetUp]
public void SetUp()
{
clock = ValkarnTaskTestHelper.Setup();
}

[TearDown]
public void TearDown()
{
ValkarnTaskTestHelper.Teardown();
}

// テストはここに
}

[SetUp]で各テストごとに1回Setupを呼び出し、[TearDown]で各テストごとに1回Teardownを呼び出してください。このパターンにより、各テストがクリーンなランタイム状態で開始し、次のテストに漏れないことが保証されます。

デフォルトのデルタタイム

SetupはオプションのdefaultDeltaTimeパラメーター(デフォルト: 1/60秒、つまり60fps)を受け入れます:

clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 30f);  // 30fpsシミュレーション

この値はTestClock.AdvanceFrame()TestClock.AdvanceFrames(n)によって使用されます。


TestClock

TestClockITimeProviderを実装し、テストに以下のフルコントロールを提供します:

  • シミュレーション時間(GetTimestamp()経由、Stopwatch.Frequencyティックでバックアップ)
  • 現在フレームのDeltaTimeUnscaledDeltaTime
  • FrameCount

時間の進行

Advance(TimeSpan duration)

指定された期間だけ時間を一ステップで進めます。期間全体がそのフレームのDeltaTimeとして適用され、FrameCountが1増加し、すべてのPlayerLoopタイミングが処理されます。処理後、DeltaTimeは呼び出し前の値に復元されます。

var task = ValkarnTask.Delay(3000);  // 3秒遅延

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted); // まだ

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted); // ちょうど3秒で完了

個々のフレームをシミュレートせずに特定の時点に飛びたい場合に使用します。

AdvanceFrame()

現在のDeltaTimeを使用して1フレーム進めます。FrameCountが1増加し、すべてのPlayerLoopタイミングが処理され、処理前に1ミリ秒のThread.Sleepが挿入されます。このスリープは、バックグラウンドワーカースレッド(RunOnThreadPoolとUnity Job Systemインテグレーションで使用)がフレームティック間に完了するための小さなウィンドウを必要とするためです — なしでは、テストがリアルフレームでは発生しない競合状態を引き起こすギャップなしにフレームを連続実行します。

clock.AdvanceFrame();

AdvanceFrames(int count)

AdvanceFrame()count回呼び出します。

clock.AdvanceFrames(10);  // 10フレームをシミュレート

ProcessTick(PlayerLoopTiming timing)

時間やFrameCountを進めずに単一のPlayerLoopタイミングフェーズを処理します。特定のタイミング(例:PlayerLoopTiming.FixedUpdate)でスケジュールされたコードをテストするとき、他のすべてのタイミングを処理したくない場合に便利です。

clock.ProcessTick(PlayerLoopTiming.FixedUpdate);

デルタタイムの制御

SetDeltaTime(float deltaTime)

DeltaTimeUnscaledDeltaTimeの両方をすべての後続フレームで同じ値に設定します。

SetDeltaTime(float deltaTime, float unscaledDeltaTime)

非単位のTime.timeScaleをシミュレートするために独立して設定します。例えば、時間が停止している間にDelayType.UnscaledDeltaTimeを使用するコードをテストする場合:

clock.SetDeltaTime(deltaTime: 0f, unscaledDeltaTime: 0.05f);

var scaledTask = ValkarnTask.Delay(500, DelayType.DeltaTime);
var unscaledTask = ValkarnTask.Delay(500, DelayType.UnscaledDeltaTime);

clock.AdvanceFrames(9); // アンスケール450ms、スケール0ms
Assert.IsFalse(scaledTask.IsCompleted); // 停止したゲーム時間は500msに到達しない
Assert.IsFalse(unscaledTask.IsCompleted); // 450ms < 500ms

clock.AdvanceFrame(); // アンスケール500ms
Assert.IsTrue(unscaledTask.IsCompleted); // アンスケール遅延完了
Assert.IsFalse(scaledTask.IsCompleted); // スケールはまだ動かない

非同期ValkarnTaskメソッドのユニットテストの書き方

同期的に完了するテスト

一部のValkarnTask操作はサスペンドせずに完了します — 例えば、ValkarnTask.CompletedTaskValkarnTask.FromResult(value)、またはawaitされる前に完了したValkarnTaskCompletionSourceをawaitする場合。これらはクロックを全く必要とせず、テストアセンブリ内部のTestHelperクラスを直接使用できます:

[TestFixture]
public class SyncTests
{
[SetUp]
public void SetUp()
{
// メインスレッドIDのみ設定 — クロックやPlayerLoopは不要
TestHelper.EnsureInitialized();
}

[Test]
public void FromResult_ReturnsValue()
{
var task = ValkarnTask.FromResult(42);
Assert.IsTrue(task.IsCompleted);
Assert.AreEqual(42, task.GetAwaiter().GetResult());
}
}

TestHelperTests/Editor/TestHelper.cs内)はテストスイート自体が使用する内部ヘルパーです:

  • TestHelper.EnsureInitialized()ValkarnTaskPoolShared.MainThreadIdを現在のスレッドに設定します。これにより、プール操作が正しい(非アトミック)ファストパスにルートされます。
  • TestHelper.RunSync(ValkarnTask task)はタスクがすでに完了していることをアサートしてGetResult()を呼び出します — 同期的に完了するはずのコードパスのテストに便利です。
  • TestHelper.RunSync<T>(ValkarnTask<T> task)は同様に結果値を返します。
  • TestHelper.UnobservedExceptionCollectorはそのスコープの間ValkarnTask.UnobservedExceptionをサブスクライブし、アサート用に未観測の例外を収集します。

時間を必要とするテスト

ValkarnTask.DelayValkarnTask.Yield、またはPlayerLoopタイミングでスケジュールされたコードを含むテストは、ValkarnTaskTestHelper.Setup()と返されるTestClockを必要とします。

例: 遅延ベースメソッドのテスト

以下のメソッドがあるとします:

public static async ValkarnTask WaitAndLog(int ms)
{
await ValkarnTask.Delay(ms);
Log("done");
}

リアルな待機なしでテスト:

[TestFixture]
public class DelayTests
{
TestClock clock;

[SetUp]
public void SetUp() => clock = ValkarnTaskTestHelper.Setup();

[TearDown]
public void TearDown() => ValkarnTaskTestHelper.Teardown();

[Test]
public void WaitAndLog_CompletesAfterDelay()
{
var task = WaitAndLog(3000);
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted);

// 結果を取得(フォルトの場合はスロー)
task.GetAwaiter().GetResult();
}

[Test]
public void WaitAndLog_ZeroDelay_CompletesImmediately()
{
var task = WaitAndLog(0);
// ValkarnTask.Delay(0)は即座にCompletedTaskを返す
Assert.IsTrue(task.IsCompleted);
}
}

キャンセルのテスト

[Test]
public void Delay_CancelledMidway_ThrowsOCE()
{
var cts = new CancellationTokenSource();
var task = ValkarnTask.Delay(5000, cts.Token);
Assert.IsFalse(task.IsCompleted);

cts.Cancel();
// キャンセルは次のティックで伝播する
clock.AdvanceFrame();

Assert.IsTrue(task.IsCompleted);
Assert.Throws<OperationCanceledException>(() => task.GetAwaiter().GetResult());
}

未観測例外の収集

[Test]
public void FaultedTask_PublishesUnobservedException()
{
using var collector = new TestHelper.UnobservedExceptionCollector();

var tcs = new ValkarnTaskCompletionSource();
var task = tcs.Task;

// awaitしない — fire and forget
task.Forget();

// 例外で完了 — 未観測パスをトリガー
tcs.TrySetException(new InvalidOperationException("oops"));

Assert.AreEqual(1, collector.Exceptions.Count);
Assert.IsInstanceOf<InvalidOperationException>(collector.Exceptions[0]);
}

例: チャネルプロデューサー/コンシューマーのテスト

[TestFixture]
public class ChannelPipelineTests
{
[SetUp]
public void SetUp() => TestHelper.EnsureInitialized();

[Test]
public void Producer_WritesItems_ConsumerReadsInOrder()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();

// プロデューサー: 同期的に書き込み
channel.Writer.TryWrite(1);
channel.Writer.TryWrite(2);
channel.Writer.TryWrite(3);
channel.Writer.Complete();

// コンシューマー: アイテムがあるチャネルへのReadAsyncは同期的に完了する
Assert.AreEqual(1, TestHelper.RunSync(channel.Reader.ReadAsync()));
Assert.AreEqual(2, TestHelper.RunSync(channel.Reader.ReadAsync()));
Assert.AreEqual(3, TestHelper.RunSync(channel.Reader.ReadAsync()));
}

[Test]
public void ReadAsync_BeforeWrite_PendsThenCompletesOnWrite()
{
var channel = ValkarnTask.Channel.CreateUnbounded<string>();

// 書き込み前に読み取りを発行
var readTask = channel.Reader.ReadAsync();
Assert.IsFalse(readTask.IsCompleted);

// 書き込みが保留中の読み取りを同期的に完了させる
channel.Writer.TryWrite("hello");
Assert.IsTrue(readTask.IsCompleted);
Assert.AreEqual("hello", readTask.GetAwaiter().GetResult());
}

[Test]
public void Complete_DrainedChannel_CompletionTaskCompletes()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();
channel.Writer.TryWrite(42);

var completion = channel.Reader.Completion;
Assert.IsFalse(completion.IsCompleted);

channel.Writer.Complete();
Assert.IsFalse(completion.IsCompleted); // アイテムがまだ未読

channel.Reader.TryRead(out _);
Assert.IsTrue(completion.IsCompleted); // ドレイン済み — 完了が発火
}

[Test]
public void ClosedChannel_Read_ThrowsChannelClosedException()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();
channel.Writer.Complete();

var readTask = channel.Reader.ReadAsync();
Assert.IsTrue(readTask.IsCompleted);
Assert.Throws<ChannelClosedException>(() => readTask.GetAwaiter().GetResult());
}
}

例: フレーム進行を使ったValkarnTask.Delayのテスト

この例では、設定可能な遅延を待つメソッドのテストで、部分的な進行がタスクを早期に完了させないことを確認する方法を示します。

[TestFixture]
public class DelayFrameTests
{
TestClock clock;

[SetUp]
public void SetUp() => clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 60f);

[TearDown]
public void TearDown() => ValkarnTaskTestHelper.Teardown();

[Test]
public void Delay_500ms_RequiresTenFramesAt50ms()
{
clock.SetDeltaTime(0.05f); // フレームあたり50ms
var task = ValkarnTask.Delay(500);

clock.AdvanceFrames(9);
Assert.IsFalse(task.IsCompleted, "450ms経過 — まだ完了しないはず");

clock.AdvanceFrame();
Assert.IsTrue(task.IsCompleted, "500ms経過 — 完了しているはず");
}

[Test]
public void Delay_Realtime_UsesTimestampNotDeltaTime()
{
// リアルタイム遅延はDeltaTimeを無視し、Stopwatchタイムスタンプを使用
clock.SetDeltaTime(0f); // DeltaTimeを固定
var task = ValkarnTask.Delay(200, DelayType.Realtime);

// AdvanceはTimeSpanを通じてタイムスタンプのみを移動させる
clock.Advance(TimeSpan.FromMilliseconds(199));
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromMilliseconds(1));
Assert.IsTrue(task.IsCompleted);
}
}

NUnitインテグレーション

テストスイートはNUnit 3.xTestRunner.csprojで3.xにピン止め)を使用します。NUnit 4はテストが使用するクラシックなAssert.IsTrue/Assert.AreEqual APIを削除しました;アサーションを制約ベースのAPIに更新しない限りNUnit 4にアップグレードしないでください。

Unity Test Framework

Unity Editorでは、Tests/Editor/のテストはUnity Test Framework(UTF)によって発見・実行されます(NUnitベース)。テストランナーインテグレーションの動作:

  • UTFは各[Test]をメインスレッドで実行します。
  • ValkarnTaskTestHelper.Setup()は各テスト前にTestClockをインストールします;UTFの[SetUp]属性がそれを呼び出します。
  • ValkarnTaskTestHelper.Teardown()[TearDown]でクロックを削除します。
  • コルーチンや[UnityTest]は不要です:すべてのValkarn Tasksテストは同期的なNUnit [Test]メソッドを使用します。TestClockが時間を手動で駆動するため、リアルなフレーム進行は不要です。

_TestRunner~プロジェクト

_TestRunner~/ディレクトリには、.NET SDKとdotnet testを使用してUnity外でテストスイート全体をコンパイルして実行するスタンドアロンの.NET 8プロジェクトが含まれています。これはCIとコントリビューターのローカル開発に使用されます。

構造

_TestRunner~/
TestRunner.csproj — .NET 8プロジェクトファイル
bin~/ — ビルド出力(gitignore済み)
obj~/ — 中間出力(gitignore済み)

動作の仕組み

TestRunner.csprojは4セットのソースを単一アセンブリにコンパイルします:

ソースセットパス備考
ランタイム../Runtime/**/*.csすべてのランタイムファイル
テスト../Testing/**/*.csValkarnTaskTestHelperTestClock
テスト../Tests/Editor/**/*.csすべての.csテストファイル
除外UnityTimeProvider.csBridge/Burst/ECS/リアルUnity APIが必要 — 除外

このプロジェクトはUNITY_5_3_OR_NEWERVTASKS_HAS_BURSTVTASKS_HAS_COLLECTIONS、またはVTASKS_HAS_ENTITIESを定義しないため、すべてのUnity固有の#if分岐はコンパイルアウトされます。つまり、テストはピュアC#コードパスを実行します。

アナライザーDLLは<Analyzer>アイテムとして読み込まれるため、IDEで発火する同じルールが_TestRunner~プロジェクトのdotnet build中にも発火します。

テストの実行

cd _TestRunner~
dotnet test

またはリポジトリルートから:

dotnet test _TestRunner~/TestRunner.csproj

除外されるテストファイル

3つのテストサブディレクトリはリアルUnityランタイムAPIが必要なため_TestRunner~プロジェクトから除外されます:

ディレクトリ理由
Tests/Editor/Bridge/Job Systemブリッジのテスト;Unity.Jobsが必要
Tests/Editor/Burst/Burstスケジューラーのテスト;Unity.Burstが必要
Tests/Editor/ECS/ECSユーティリティのテスト;Unity.Entitiesが必要

これらのテストはUnity Test Framework経由でUnity Editorの内部でのみ実行されます。

新しいテストの追加

  1. Tests/Editor/以下(Unity APIサブディレクトリ内ではない)に新しい.csファイルを追加。
  2. クラスを[TestFixture]で、メソッドを[Test]で修飾。
  3. テストが時間制御を必要とする場合は[SetUp]/[TearDown]ValkarnTaskTestHelper.Setup()/Teardown()を使用。
  4. テストが同期タスクアサーションのみ必要な場合(遅延なし)は[SetUp]TestHelper.EnsureInitialized()を使用 — teardownは不要。
  5. dotnet test _TestRunner~/TestRunner.csprojを実行してUnity外で確認。
  6. Unity Test Frameworkを実行してUnity内で確認。