テスト

このガイドでは、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、状態検証
外部依存 インターフェース + モックパターン

参照


This site uses Just the Docs, a documentation theme for Jekyll.