yotiky Tech Blog

とあるエンジニアの備忘録

Unity Unit Test(単体テスト)入門 - Extenject(Zenject)

目次

はじめに

  • この一連の記事は
    • UnityのUnity Test Frameworkを使ったテストに関して調べたメモ書きに補足を足したもの
    • Unityのテスト、およびDIコンテナ、モックライブラリの基本的な使い方やそれぞれの役割など入門レベルの解説
    • ライブラリのリファレンス的な使い方などについては公式や他の記事参照
    • 実機テストやCIは含まない
    • TDDに関しては考慮しない

シリーズの目次

環境

Extenject(Zenject)

手順

  • AssetStoreからExtenjectを追加する
  • アプリケーションのasmdefに、zenjectの参照を追加する
  • 基本的な使い方
    • Inject対象を実装する
      • インジェクト方法は4つ
        • フィールドインジェクション(メンバにInject属性をつける)
        • プロパティインジェクション
        • メソッドインジェクション
        • コンストラクタインジェクション(属性なし、引数にバインドされる)
      • MonoBehaviour
        • コンストラクタ使えないので初期化メソッドを定義してメソッドインジェクション
    • MonoInstallerを継承したInstallerを作成して、依存関係を定義する
    • HierarchyでContextを追加してInstallerをアサインする

テストでの使い方

  • テストのasmdefに、zenjectZenject-TestFrameworkZenject-usage.dllの参照を追加する
  • テストタイプは3種類
    • UnitTest
    • IntegrationTest
    • SceneTest
  • UnitTestは、ZenjectUnitTestFixtureを継承する
  • IntegrationTestは、ZenjectIntegrationTestFixtureを継承する
    • UnityTest属性向け、フレームを跨ぐテスト
    • Test属性使ってもコケたりはしない
    • PlayModeでのみ使える
    • IInitializable等のZenjectのインターフェースを使った機能も動作する
  • SceneTestは、SceneTestFixtureを継承する
  • 継承元のContainerプロパティを使って、テスト用の依存関係を定義する
  • インジェクトされたいインスタンスInjectするか、Resolveインスタンスを取得して検証する
  • モックライブラリを使ってない場合は、テスト用のモッククラスを定義するなどする
  • Zenject/Documentation/WritingAutomatedTests.md at master · modesttree/Zenject

サンプル

アプリ側

  • Sword クラス
public interface ISword
{
    string Name { get; }
}

public partial class Sword : ISword, IInitializable
{
    private string _material;
    private string _author;

    public string Name => $"{_material}の剣";

    public Sword(string material, string author)
    {
        _material = material;
        _author = author;
        Debug.Log($"{Name} made by {_author}");
    }

    public void Initialize()
    {
        Debug.Log("Initialize called.");
    }
}
  • Player クラス
public class Player
{
    private ISword _sword;
    public ISword Sword => _sword;

    public Player(ISword sword)
    {
        _sword = sword;
    }
}
  • ZenjectSample クラス
public class ZenjectSample : MonoBehaviour
{
    [Inject]
    private Player player;

    public Player Player => player;

    void Start()
    {
        Debug.Log(player.Sword);
    }
}
  • ZenjectSampleInstaller
public class ZenjectSampleInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesTo<Sword>()
            .AsTransient()
            .WithArguments("鋼", "馴染みの鍛冶職人")
            .IfNotBound();

        Container.Bind<Player>()
            .AsTransient()
            .IfNotBound();
    }
}

テストコード

  • Mockクラス
public class SwordMock : ISword, IInitializable
{
    private string _name;
    public string Name => _name;
    public SwordMock(string name)
    {
        this._name = name;
    }

    public void Initialize()
    {
        // ZenjectUnitTestFixtureでは呼ばれない
        Debug.Log("Initialize called.");
    }
}

UnitTest

[TestFixture]
public class ZenjectUnitTest : ZenjectUnitTestFixture
{
    [Inject]
    private ISword _target;

    [SetUp]
    public void CommonInstall()
    {
        Container.BindInterfacesTo<SwordMock>()
            .AsTransient()
            .WithArguments("試し打ちの棍棒");

        Container.Bind<Player>()
            .AsTransient();

        Container.Bind<ZenjectSample>()
            .FromNewComponentOnNewGameObject()
            .AsTransient();

        // Injectされたいインスタンスを渡すこともできる
        Container.Inject(this);
    }

    [Test]
    public void InjectTypeTest()
    {
        Assert.That(_target, Is.InstanceOf<SwordMock>());
    }

    [Test]
    public void InjectValueTest()
    {
        // コンテナから直接取り出す場合
        var target = Container.Resolve<ISword>();
        Assert.That(target.Name, Is.EqualTo("試し打ちの棍棒"));
    }

    [UnityTest]
    public IEnumerator UnityTest属性のTest()
    {
        yield return null;
        var target = Container.Resolve<ZenjectSample>();
        Assert.That(target.Player.Sword.Name, Is.EqualTo("試し打ちの棍棒"));
        yield return null;
    }
}

IntegrationTest

public class ZenjectIntegrationTest : ZenjectIntegrationTestFixture
{
    [Inject]
    private ISword _target;

    private void CommonInstall()
    {
        PreInstall();

        Container.BindInterfacesTo<SwordMock>()
            .AsTransient()
            .WithArguments("ひのきの棒");

        Container.Bind<Player>()
            .AsTransient();

        Container.Bind<ZenjectSample>()
            .FromNewComponentOnNewGameObject()
            .AsTransient();

        PostInstall();
    }

    [Test]
    public void InjectTypeTest()
    {
        CommonInstall();
        Assert.That(_target, Is.InstanceOf<SwordMock>());
    }

    [Test]
    public void InjectValueTest()
    {
        CommonInstall();
        // コンテナから直接取り出す場合
        var target = Container.Resolve<ISword>();
        Assert.That(target.Name, Is.EqualTo("ひのきの棒"));
    }

    [UnityTest]
    public IEnumerator UnityTest属性のTest()
    {
        CommonInstall();
        yield return null;

        var target = Container.Resolve<ZenjectSample>();
        Assert.That(target.Player.Sword.Name, Is.EqualTo("ひのきの棒"));
        yield return null;
    }
}

SceneTest

public class ZenjectSceneTest : SceneTestFixture
{
    [UnityTest]
    public IEnumerator TestScene()
    {
        StaticContext.Container
            .BindInterfacesTo<SwordMock>()
            .AsTransient()
            .WithArguments("妖刀村正");

        StaticContext.Container
            .Bind<Player>()
            .AsTransient();

        yield return LoadScene("ZenjectSample");

        var injected = SceneContainer.Resolve<ISword>();
        Assert.That(injected, Is.InstanceOf<SwordMock>());
        yield return null;

        var obj = GameObject.Find("GameObject");
        var target = obj.GetComponent<ZenjectSample>();
        Assert.That(target.Player.Sword, Is.InstanceOf<SwordMock>());
        Assert.That(target.Player.Sword.Name, Is.EqualTo("妖刀村正"));
    }
}

参考