テスト
このガイドでは、Unity Test Framework(NUnit)を使用したReactive SOコンポーネントのユニットテストパターンを解説します。
目的
このガイドでは、Reactive SOコンポーネントのユニットテストの書き方を説明します。Event Channels、Variables、Runtime Sets、Reactive Entity Setsのテストパターンと、外部依存を分離するための依存性注入テクニックを学びます。
前提条件
Unity Test Framework
Unity Test FrameworkはUnityに同梱されています。Test Runnerは以下のメニューからアクセスします。
Window > General > Test Runner
Edit Mode vs Play Mode
| モード | 用途 | 速度 |
|---|---|---|
| Edit Mode | ScriptableObjectロジック、純粋なC#クラス | 高速 |
| Play Mode | MonoBehaviourライフサイクル、シーン統合 | 低速 |
ScriptableObjectはシーンなしでインスタンス化できるため、ほとんどのReactive SOテストはEdit Modeで実行します。
Event Channelsのテスト
基本パターン
テストでScriptableObjectインスタンスを直接作成します。
using NUnit.Framework;
using UnityEngine;
using Tang3cko.ReactiveSO;
public class IntEventChannelSOTests
{
private IntEventChannelSO channel;
[SetUp]
public void Setup()
{
// アセットファイルなしでインスタンスを作成
channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
[TearDown]
public void Teardown()
{
// Edit ModeではDestroyImmediateを使用
Object.DestroyImmediate(channel);
}
[Test]
public void RaiseEvent_WithSubscriber_NotifiesSubscriberWithValue()
{
// Arrange
int receivedValue = 0;
channel.OnEventRaised += (value) => receivedValue = value;
// Act
channel.RaiseEvent(42);
// Assert
Assert.That(receivedValue, Is.EqualTo(42));
}
}
複数サブスクライバーのテスト
[Test]
public void RaiseEvent_WithMultipleSubscribers_NotifiesAll()
{
// Arrange
int notificationCount = 0;
channel.OnEventRaised += (value) => notificationCount++;
channel.OnEventRaised += (value) => notificationCount++;
channel.OnEventRaised += (value) => notificationCount++;
// Act
channel.RaiseEvent(10);
// Assert
Assert.That(notificationCount, Is.EqualTo(3));
}
購読解除のテスト
[Test]
public void Unsubscribe_AfterRaiseEvent_DoesNotNotify()
{
// Arrange
bool wasNotified = false;
System.Action<int> handler = (value) => wasNotified = true;
channel.OnEventRaised += handler;
channel.OnEventRaised -= handler;
// Act
channel.RaiseEvent(42);
// Assert
Assert.That(wasNotified, Is.False);
}
Variablesのテスト
基本パターン
public class IntVariableSOTests
{
private IntVariableSO variable;
private IntEventChannelSO eventChannel;
[SetUp]
public void Setup()
{
variable = ScriptableObject.CreateInstance<IntVariableSO>();
eventChannel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(variable);
Object.DestroyImmediate(eventChannel);
}
[Test]
public void Value_Set_ChangesValue()
{
// Arrange
variable.Value = 10;
// Act
variable.Value = 20;
// Assert
Assert.That(variable.Value, Is.EqualTo(20));
}
}
変更検出のテスト
Variablesは値が実際に変更されたときのみイベントを発火します。
[Test]
public void Value_SetSameValue_DoesNotRaiseEvent()
{
// Arrange
variable.Value = 42;
bool eventRaised = false;
// リフレクションでイベントチャネルを設定(内部フィールド)
var field = typeof(VariableSO<int>).GetField("onValueChanged",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
field.SetValue(variable, eventChannel);
eventChannel.OnEventRaised += (value) => eventRaised = true;
// Act
variable.Value = 42; // 同じ値
// Assert
Assert.That(eventRaised, Is.False);
}
Runtime Setsのテスト
基本パターン
public class GameObjectRuntimeSetSOTests
{
private GameObjectRuntimeSetSO runtimeSet;
private GameObject testObject;
[SetUp]
public void Setup()
{
runtimeSet = ScriptableObject.CreateInstance<GameObjectRuntimeSetSO>();
testObject = new GameObject("TestObject");
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(testObject);
Object.DestroyImmediate(runtimeSet);
}
[Test]
public void Add_ValidObject_IncreasesCount()
{
// Arrange
Assert.That(runtimeSet.Count, Is.EqualTo(0));
// Act
runtimeSet.Add(testObject);
// Assert
Assert.That(runtimeSet.Count, Is.EqualTo(1));
}
}
Reactive Entity Setsのテスト
テスト専用サブクラスパターン
ジェネリッククラスをテストするために具象実装を作成します。
public class ReactiveEntitySetSOTests
{
// テストデータ構造
public struct TestEntityData
{
public int Health;
public float Speed;
}
// テスト実装
private class TestReactiveEntitySetSO : ReactiveEntitySetSO<TestEntityData>
{
}
// テスト用オーナーコンポーネント
private class TestOwner : MonoBehaviour { }
private TestReactiveEntitySetSO entitySet;
private GameObject ownerObject;
private TestOwner owner;
[SetUp]
public void Setup()
{
entitySet = ScriptableObject.CreateInstance<TestReactiveEntitySetSO>();
ownerObject = new GameObject("Owner");
owner = ownerObject.AddComponent<TestOwner>();
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(ownerObject);
Object.DestroyImmediate(entitySet);
}
[Test]
public void Register_ValidOwner_AddsToSet()
{
// Arrange
var data = new TestEntityData { Health = 100, Speed = 5f };
// Act
entitySet.Register(owner, data);
// Assert
Assert.That(entitySet.Count, Is.EqualTo(1));
Assert.That(entitySet.Contains(owner), Is.True);
}
[Test]
public void UpdateData_ModifiesState()
{
// Arrange
entitySet.Register(owner, new TestEntityData { Health = 100 });
// Act
entitySet.UpdateData(owner, state => {
state.Health -= 30;
return state;
});
// Assert
var data = entitySet.GetData(owner);
Assert.That(data.Health, Is.EqualTo(70));
}
}
応用:スナップショットベースのテスト
Snapshot APIを使用すると、保存済みの状態をロードしてロジックの動作を検証できます。これは複雑なバグの再現に非常に有効です。
[Test]
public void Snapshot_Logic_RegressionTest()
{
// 1. Arrange: スナップショットを構築(またはファイルからロード)
// 配列を確保
int count = 1;
var data = new NativeArray<EnemyState>(count, Allocator.Temp);
var ids = new NativeArray<int>(count, Allocator.Temp);
// "バグ"の状態をセット
data[0] = enragedBossState;
ids[0] = bossId;
// スナップショット構造体を作成
var snapshot = new EntitySetSnapshot<EnemyState>(data, ids, count);
// 2. Act: 新しいセットに状態を復元
var set = ScriptableObject.CreateInstance<EnemyEntitySetSO>();
set.RestoreSnapshot(snapshot);
// 3. Act: 特定のロジックを実行
battleSystem.ProcessTick(set);
// 4. Assert: 結果を検証
Assert.IsTrue(set[bossId].HasAttacked);
// スナップショットを破棄(内部の配列も破棄されます)
snapshot.Dispose();
}
統合テスト(Integration Test)パターン
Reactive SOコンポーネントは隔離されているため、複数のシステムをEdit Modeテストで連結し、複雑なワークフローを検証できます。
public class BattleWorkflowTests
{
[Test]
public void FullBattleTick_IntegrationTest()
{
// 複数のシステムをセットアップ
var enemies = ScriptableObject.CreateInstance<EnemySetSO>();
var player = ScriptableObject.CreateInstance<PlayerVariableSO>();
var log = ScriptableObject.CreateInstance<LogChannelSO>();
// 依存関係を手動で注入
var strategySystem = new StrategySystem(enemies);
var combatSystem = new CombatSystem(enemies, player, log);
// 1. Arrange: 初期状態
enemies.Register(1, new EnemyState { HP = 10, Pos = Vector3.forward });
player.Value = new PlayerState { HP = 100 };
// 2. Act: 一連のロジックを実行
strategySystem.Update();
combatSystem.Update();
// 3. Assert: 複数システムにまたがる結果を検証
Assert.Less(player.Value.HP, 100, "プレイヤーがダメージを受けるはず");
// イベントチャネルの可観測性を利用して検証
// (LogChannelSOがテスト用に呼び出し履歴を保持していると仮定)
Assert.IsTrue(log.Contains("Player was hit!"));
}
}
依存性注入パターン
外部依存(ファイルI/O、ダイアログ)を持つクラスには、インターフェースと手動モックを使用します。
インターフェースの定義
public interface IFileService
{
string SaveFilePanel(string title, string directory,
string defaultName, string extension);
void WriteAllText(string path, string content, Encoding encoding);
void RevealInFinder(string path);
}
モック実装の作成
public class MockFileService : IFileService
{
public string PathToReturn { get; set; }
public bool WriteAllTextCalled { get; private set; }
public string LastWrittenPath { get; private set; }
public string LastWrittenContent { get; private set; }
public string SaveFilePanel(string title, string directory,
string defaultName, string extension)
{
return PathToReturn;
}
public void WriteAllText(string path, string content, Encoding encoding)
{
WriteAllTextCalled = true;
LastWrittenPath = path;
LastWrittenContent = content;
}
public void RevealInFinder(string path) { }
}
テストでの使用
public class ExporterTests
{
private MockFileService mockFileService;
private MockDialogService mockDialogService;
private Exporter exporter;
[SetUp]
public void Setup()
{
mockFileService = new MockFileService();
mockDialogService = new MockDialogService();
exporter = new Exporter(mockDialogService, mockFileService);
}
[Test]
public void Export_WithValidPath_WritesFile()
{
// Arrange
mockFileService.PathToReturn = "/path/to/file.csv";
// Act
exporter.Export(testData);
// Assert
Assert.That(mockFileService.WriteAllTextCalled, Is.True);
Assert.That(mockFileService.LastWrittenPath,
Is.EqualTo("/path/to/file.csv"));
}
[Test]
public void Export_UserCancels_DoesNotWriteFile()
{
// Arrange
mockFileService.PathToReturn = ""; // ユーザーがキャンセル
// Act
exporter.Export(testData);
// Assert
Assert.That(mockFileService.WriteAllTextCalled, Is.False);
}
}
ベストプラクティス
AAAパターンに従う
テストを明確なセクションで構造化します。
[Test]
public void MethodName_Scenario_ExpectedBehavior()
{
// Arrange - テストデータと条件をセットアップ
// Act - テスト対象のコードを実行
// Assert - 結果を検証
}
常にクリーンアップ
Edit ModeテストではDestroyImmediateを使用します。
[TearDown]
public void Teardown()
{
// DestroyはEdit Modeでは即座に機能しない
Object.DestroyImmediate(channel);
}
エッジケースをテスト
[Test]
public void RaiseEvent_WithoutSubscribers_DoesNotThrow()
{
Assert.DoesNotThrow(() => channel.RaiseEvent(42));
}
[Test]
public void RaiseEvent_WithMinValue_TransmitsMinValue()
{
int receivedValue = 0;
channel.OnEventRaised += (value) => receivedValue = value;
channel.RaiseEvent(int.MinValue);
Assert.That(receivedValue, Is.EqualTo(int.MinValue));
}
テストの独立性を保つ
各テストは単独で実行可能であるべきです。
// 悪い例:テスト間で状態を共有
private static IntEventChannelSO sharedChannel;
// 良い例:各テストが独自のインスタンスを作成
[SetUp]
public void Setup()
{
channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
}
よくある落とし穴
ScriptableObjectの破棄忘れ
CreateInstanceで作成したScriptableObjectは破棄するまで残り続けます。常にTearDownでクリーンアップしてください。
CreateInstanceの代わりにnewを使用
// 悪い例:エラーになる
var channel = new IntEventChannelSO();
// 良い例:ScriptableObjectの正しい作成方法
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
不必要にPlay Modeでテスト
ほとんどのReactive SOロジックはEdit Modeでテスト可能であり、より高速です。
まとめ
| コンポーネント | 主要パターン |
|---|---|
| Event Channels | CreateInstance、サブスクライブ、コールバック検証 |
| Variables | CreateInstance、値設定、変更検出確認 |
| Runtime Sets | GameObject作成、追加/削除、カウント検証 |
| Reactive Entity Sets | テストサブクラス、Register/UpdateData、状態検証 |
| 外部依存 | インターフェース + モックパターン |
参照
- テスト容易性 - Reactive SOがなぜテスト可能か
- Unity Test Framework - 公式ドキュメント
- NUnit Documentation - テストフレームワークリファレンス