Mikimemo

個人的な技術・開発メモやポエム

Zenjectとかが絡むコンポーネントのUnit Test

Zenjectが絡んだときのUnit Testの例を紹介します。 あくまで例です。

例えば以下のようなHomeUseCaseというクラスのテストを行う場合を考えます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using Zenject;
using System;

public interface IHomeUseCase : IUseCase
{
    IObservable<IHomeViewModel> LoadHomeModel();
}

public class HomeUseCase : IHomeUseCase
{
    [Inject] IUserRepository userRepository;
        
    public IObservable<IHomeViewModel> LoadHomeModel()
    {
        return userRepository.Get()
            .Select(entity => new HomeViewModel(entity));
    }
}

HomeUserCaseはLoadHomeModel()をコールすると、 いろいろやってIHomeViewModelというインターフェースを取得するためのObservableを返却します。

また、HomeUseCaseはIUserRepositoryに依存しており、Injectアトリビュートで依存注入されています。

ちなみにUserRepositoryは以下のようなIOを担当するようなコンポーネントで、 通常はサーバー通信を行うコンポーネントとのインターフェースになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using Zenject;
using System;

public interface IUserRepository : IRepository
{
    IObservable<UserEntity> Get();
}

public class UserRepository : IUserRepository
{
    [Inject] IUserDataStore dataStore;

    public IObservable<UserEntity> Get()
    {
        return dataStore.Get(); 
    }
}

この時点でHomeUseCaseをテストするためには以下の要点をどうにかする必要があります。

  1. TestコードでInjectアトリビュートやContainerをどう扱うか
  2. UserRepositoryをそのまま使うとサーバーへ通信してしまうし、そもそもHomeUseCaseのテストにフォーカスしたい
  3. UniRx使っていて非同期ぽいのをどうテストするか

解決方針

こんな状態のコードをテストをするために以下の2つの方針をたてます。

ZenjectUnitTestFixtureを使う

「1.」についてはZenjectのOptionalExtras以下にあるZenjectUnitTestFixtureを継承したクラスでテストを行うことで解決できます。 ZenjectUnitTestFixture継承したクラスでは、デフォルトでContainerを扱うことができ、 そこにテストに必要なコンポーネントをバインドしていくことでInjectアトリビュート等を扱ったテストが可能になります。

Mockフレームワークを使う

「2. 3.」に関してはMockを利用します。 Mockは本物のオブジェクトの代わりにテスト用のオブジェクトに差し替える仕組みです。 これにより、依存するオブジェクトをすべて用意する必要がなくなり、 テストしたいものだけにフォーカスできます。 また、依存するオブジェクトの状態も自由に用意できます。

ZenejctにはOptionalExtras以下にMockフレームワークのMoqが付属しているのでzipファイルを解答することで利用可能になります。

OptionalExtras以下にあるAutoMockingをダブルクリック後に展開されるファイルの中に Moq-Net35、Moq-Net46があるので、Unity Editorの状態に合わせて再度ダブルクリックします。 するとMoqディレクトリが展開されます。

f:id:miki05:20180821001353p:plain

実際のテストコード

このあたりを含め以下のようなテストコードを実装してみました。

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using Zenject;
using UniRx;
using Moq;

public class HomeUseCaseTest : ZenjectUnitTestFixture // ①
{
    [Inject] HomeUseCase useCase; // ⑥
    [Inject] Mock<IUserRepository> mockRepository; // ⑥

    [SetUp]
    public void CommonInstall()
    {
        Container.Bind<HomeUseCase>().AsSingle(); // ②

        var mockRepository 
            = new Mock<IUserRepository>(); // ③
        var mockDataObservable 
            = Observable.Return<UserEntity>(new UserEntity());
        mockRepository.Setup(x => x.Get()).Returns(mockDataObservable);

        Container.BindInstance(mockRepository.Object); // ④
        Container.BindInstance(mockRepository).AsSingle();  // ⑤

        Container.Inject(this); // ⑥
    }

    [Test]
    public void CallRepositoryAndReturnsHomeViewModel()
    {
        IHomeViewModel viewModel = null; 
        useCase.LoadHomeModel()
            .Subscribe(vm => viewModel = vm); // ⑦
        Assert.IsNotNull(viewModel);
        mockRepository.Verify(x => x.Get(), Times.Once()); // ⑧
    }
}

説明箇所ありすぎなので番号を振っています

①ZenjectUnitTestFixtureの継承

テストコードはZenjectUnitTestFixtureクラスを継承します。 これでContainer等の扱いが可能になり(Baseクラスで定義)、Zenjectのテストがしやすくなります。

②CommonInstall内でのContainerへのBind

テストに必要なコンポーネントはContainerへBindしていきます。 まずはテスト対象のHomeUseCaseをバインドしています。 CommonInstallはSetupアトリビュートによりテストの前に実行されます。

③依存コンポーネントのMock生成

通常のZenjectのContainerと同様、依存するコンポーネントはContainerの中にBindしておく必要があります。 テスト対象のHomeUseCaseはIUserRepositoryに依存しているためにどうにか用意しBindします。

しかしUserRepository自体をBindすると以下の問題がでます。

  1. UserRepositoryが依存しているクラスもBindする必要がある
  2. HomeUseCaseのテストなのにUserRepositoryの状態に深く影響される

ということで、ここでMockを利用しています。 Mockを利用するためには対象のクラスにインターフェース定義が必要です。

ここではIUserRepositoryが定義されており、 HomeUseCaseでもすべてインターフェースで取り回ししているため、Mockでのテストが可能になります。

Moqの詳しい利用法は省略しますが、新しいMockオブジェクトを生成し、 Get()メソッドをスタブし、「即時UserEntityを返却するObservable」を返却するようにしています。

④MockUserRepositoryのBind

MockオブジェクトのObjectプロパティをBindすることで、Mockオブジェクトのインターフェースをbindできます

⑤Mockオブジェクト自体のBind

今回は振る舞いに関してもテストしたかったので(あとで解説)モックオブジェクト自体もBindしています

⑥テストクラス自体のBind、テスト対象のInject

最後にテストクラス自体もBindしてます。 これはちょっとしたテストコードで楽をするための工夫で、 こうすることにより、 冒頭に宣言したInjectアトリビュートのプロパティにContainer内のテスト対象のクラスがInejctされ、そのまま利用できるようになります。

⑦実際のテスト

LoadHomeModelメソッドを実際にコールし取得したObservableを稼働させることでテストを実行します。 ③で本来は非同期のObsevableを即時値を返すように設定しているので、 Subscribeをコールした直後、OnNextのストリームが流れSubscribe内のラムダに処理が移ります。 ここでIHomeViewModelがちゃんと返却されるかどうかをテストしています。

⑧UseCaseの振る舞いのテスト

一応、UseCase内でちゃんとUserRepositoryを利用しているかをチェックしています。 やろうと思えばUserRepositoryを経由せず、内部でクラスをインスタンス化しても⑦のテストは通すことはできるので。

⑧ではmockRepositoryのGetメソッドが1回呼ばれているかどうかをチェックしています。

まとめ

Zenejctとかを使う場合で依存が発生する場合のテストの例を挙げました

  • ZenjectUnitTestFixtureを使うとContainerを利用したUnit Testができる
  • MockフレームワークとInterfaceを利用することで対象のテストにフォーカスできる

正直UniRxの非同期の部分をこのようにテストすべきかどうかはちょっと不明です。 そのほかPlayModeTestを利用する方法もありますが、オーバーヘッドが大きいような?

それとMoqを利用しましたが、 MockライブラリとしてはNSubstitute(http://nsubstitute.github.io/)のほうが扱いやすい感じ。 ただMoqの方はZenjectにバンドルされており、単純なMockであればもっと簡単にかけたりします。