📚

Unity コンポーネントの単体テストでのフィールドの初期化

2024/02/13に公開

Unity コンポーネントの単体テストでのフィールドの初期化

Unity コンポーネントの単体テスト (NUnit などでのテスト) は正直めんどくさいです。いつも書きなれているピュアなクラスに対するテストと違い、Unity への深い理解が必要になります。

ゲームのロジックはピュアなクラスに書いて、Unity コンポーネントからそれを呼び出すスタイルをお勧めします。これなら、Unity でテストをどうするか? みたいなことに悩まされません。

とはいえ、Unity コンポーネントをテストする必要もあるわけでして。その時に面倒なのが、Unity コンポーネントのシリアライズ可能なフィールドの初期化です。

何とかなったので報告です。

面倒な理由

Unity コンポーネントは Unity Engine 管理しており、MonoBehaviour を継承しないピュアなクラスとライフサイクルが異なります。

例えば、ピュアなクラスは次のようにインスタンス化できます。

var target = new Sample("さんぷる");

// 以下 Sample クラスのテスト

Unity コンポーネントで、愚直に書くと、次のようになります。

var gameObject = new GameObject();
var target = gameObject.AddComponent<Sample>();
target.text = "さんぷる";

// 以下 Sample クラスのテスト

しかし、この方法には問題があります。

  • text フィールドへの値の設定が、Awake()Start() メソッドより後に実行される。これは通常の Unity の動作とは逆です。
  • text フィールドのアクセス修飾子を公開する必要がある。(ないしは、値を書き換えるプロパティやメソッドの作成)

もちろん、がんばればこれらの問題は解決できます。しかし、そのためにはテストのために Unity コンポーネントを大きく修正する必要があります。テストのための修正が Unity コンポーネントの複雑さを招き、思いもよらぬバグを産みだします。テストコードを書いたはずなのに品質がかえって下がるかもしれません。

もっとシンプルに

テストは大事ですが、テストのために複雑化するのはあまりよくありません。というわけで、もっとシンプルな方法がないかを改めて考えます。

Unity Engine は GameObject が非アクティブな間 Awake() メソッドを呼び出しません。これを使えば、フィールド変数の初期化後に Awake() メソッドを呼び出すことができます。

class MyClass : MonoBehaviour
{
    [SerializeField] private string text;

    void Awake()
    {
        // 初期化
    }

    public static MyClass AttachTo(GameObject gameObject, string text)
    {
        var myClass = gameObject.AddComponent<MyClass>();
        myClass.text = text;
        return myClass;
    }
}

テストコードでは次のようにします。

var gameObject = new GameObject();
gameObject.SetActive(false); // いったん非アクティブにしておく
var myClass = MyClass.AttachTo(gameObject, "なんか");
gameObject.SetActive(true); // アクティブにして Awake() を実行させる

無事、text フィールドを非公開なままで、Awake() メソッドより先にフィールドの初期化を完了させることができました。

テストのための Unity コンポーネントを修正する必要はありますが、AttachTo() メソッド以外はテストのための修正はありません。また、AttachTo() メソッドはテストに限らず、コードで Unity コンポーネントを組み立てるときにも使えます。

もうちょっとしっかり

GameObject を非アクティブするのをうっかり忘れそうなので、非アクティブかどうかを AttachTo() メソッドで検査してもいいかもしれません。

もうちょい頑張るなら、AttachTo() メソッドで GameObject を一時的に非アクティブにする方法もあります。

public static MyClass AttachTo(GameObject gameObject, string text)
{
    var isActive = gameObject.activeSelf;
    gameObject.SetActive(false);
    
    try
    {
        var myClass = gameObject.AddComponent<MyClass>();
        myClass.text = text;
        return myClass;
    }
    finally
    {
        gameObject.SetActive(isActive);
    }
}

毎回書くのは面倒ですけど、ヘルパークラスを作っておけばそうでもありません。

class DisabledScope : IDisposable
{
    private readonly GameObject _gameObject;
    private readonly bool _originalActive;

    public DisabledScope(GameObject gameObject)
    {
        _gameObject = gameObject;
        _originalActive = gameObject.activeSelf;

        gameObject.SetActive(false);
    }

    public void Dispose()
    {
        _gameObject.SetActive(_originalActive);
    }
}
public static MyClass AttachTo(GameObject gameObject, string text)
{
    using var scope = new DisabledScope(gameObject);
    var myClass = gameObject.AddComponent<MyClass>();
    myClass.text = text;
    return myClass;
}

最後に

Unity に限った話ではないのですが、特に古いフレームワークはテスタビリティが低く、大変です。

var gameObject = new GameObject();
var target = gameObject.AddComponent(new Sample("さんぷる"));

のように書けるだけで全然違うんですけどねぇ...

Discussion