Xamarin のクラスを特に意識せずに実装してしまうと、UnitTest コードの記述が非常に困難になることがあります
例えばこんな ViewModel を書いてしまうと大変
/// <summary> /// 最初の画面の ViewModel /// </summary> public class TopPageViewModel : ViewModelBase { #region Items プロパティ /// <summary> /// リストアイテム /// </summary> private IList<PhotoItem> items = new ObservableCollection<PhotoItem>(); /// <summary> /// リストアイテム の取得と設定 /// </summary> public IList<PhotoItem> Items { get { return this.items; } set { this.SetProperty<IList<PhotoItem>>(ref this.items, value); } } #endregion //Items プロパティ /// <summary> /// コンストラクタ /// </summary> public TopPageViewModel() { App.Logger.WriteLog("ctor"); App.PhotoRepository.Load(); this.Items.Clear(); foreach (var photo in App.PhotoRepository.Items) { this.Items.Add(photo); } } }
こんな感じで、ログ出力をする Logger と写真情報を保持する PhotoRepository というクラスを Singleton にして App クラスに参照を待たせて利用した場合
単に実行するだけならまぁ特に問題はないのですが、この ViewModel は App、Logger、PhotoRepository の3つのクラスに依存しているのがよろしくありません
UnitTest のコードを記述する場合に対象となるこの ViewModel だけでなく、依存する3つのクラスもスタブとして適切に参照できるようにしておかないといけないからです
このままのコードで UnitTest コードを書く場合、おそらく Reflection を使って App クラスを強引に置き換えるといっ面倒なコードを書くはめになります;
こういったクラス間の密結合を分解して、疎結合にしてくれるのが DI コンテナである Unity です!
まずは上記の ViewModel を次のように書き換えます
/// <summary> /// 最初の画面の ViewModel /// </summary> public class TopPageViewModel : ViewModelBase { #region Privates /// <summary> /// ログ出力サービス /// </summary> private ILogger logger; /// <summary> /// リポジトリ /// </summary> private IPhotoRepository repository; #endregion //Privates #region Items プロパティ /// <summary> /// リストアイテム /// </summary> private IList<PhotoItem> items = new ObservableCollection<PhotoItem>(); /// <summary> /// リストアイテム の取得と設定 /// </summary> public IList<PhotoItem> Items { get { return this.items; } set { this.SetProperty<IList<PhotoItem>>(ref this.items, value); } } #endregion //Items プロパティ /// <summary> /// コンストラクタ /// </summary> /// <param name="logger">ログ出力サービス(DI コンテナにより自動注入される)</param> /// <param name="repository">リポジトリ(DI コンテナにより自動注入される)</param> public TopPageViewModel(ILogger logger, IPhotoRepository repository) { this.logger = logger; this.logger.WriteLog("ctor"); this.repository = repository; this.repository.Load(); this.Items.Clear(); foreach (var photo in this.repository.Items) { this.Items.Add(photo); } } }
外部に依存するものはコンストラクタの引数としてインタフェースの型として渡すように変えました
Logger や PhotoRepository はインスタンスそのものを直接利用せずに、他のクラスに置き換えできるようにインタフェースにするというわけです
このような形にするとコンストラクタの引数を見るだけで、対象のクラスが外部のどんなクラスや機能に依存しているかわかりやすくて保守性も向上しますね
さらにアプリの起動処理の部分に次のような処理を追加します
/// <summary> /// 共通 アプリケーションクラス /// </summary> public class App { /// <summary> /// アプリケーションクラス参照 /// </summary> public static readonly App Current; /// <summary> /// アプリ内で管理するモジュールのコンテナ /// </summary> public static readonly UnityContainer Container = new UnityContainer(); /// <summary> /// コンストラクタ /// </summary> static App() { Current = new App(); } /// <summary> /// コンストラクタ /// </summary> private App() : base() { // Prism.Mvvm.Xamarin アセンブリを読み込み可能にするおまじない var autoWired = ViewModelLocator.AutoWireViewModelProperty.DefaultValue; } /// <summary> /// アプリケーション起動処理 /// </summary> public void OnLaunchApplication() { // ログ出力サービスを DI コンテナに登録 Container.RegisterType<ILogger, Logger>(new ContainerControlledLifetimeManager()); // Model を DI コンテナに登録 Container.RegisterType<IPhotoRepository, PhotoRepository>(new ContainerControlledLifetimeManager()); // ViewModel をインスタンス化するデフォルトメソッドを指定します ViewModelLocationProvider.SetDefaultViewModelFactory((type) => Container.Resolve(type)); } /// <summary> /// メイン画面を取得します /// </summary> /// <returns>メイン画面</returns> public static Page GetMainPage() { return new TopPage(); } }
Unity に ILogger が要求された場合、Logger を、IPhotoRepository は PhotoRepository を渡すように設定し、ViewModelLocator のインスタンス化を Unity が行うように設定しています
これでアプリが実際に動作する場合には Unity が TopPageViewModel のコンストラクタ引数にそれぞれのインスタンスを注入してくれるので動作としては最初のコードとほぼ同じになります
ただ、UnitTest コードを書く場合は大違い
/// <summary> /// ViewModel 単体テストコード /// </summary> [TestClass] public class ViewModelTest { #region Test Initilize & Cleanup /// <summary> /// クラス初期化処理 /// </summary> [ClassInitialize] public static void ClassInitilize(TestContext context) { } /// <summary> /// 1テストケース初期化処理 /// </summary> [TestInitialize] public void Initialize() { } /// <summary> /// 1テストケース終了処理 /// </summary> [TestCleanup] public void Cleanup() { } /// <summary> /// クラス終了処理 /// </summary> [ClassCleanup] public static void ClassCleanup() { } #endregion //Test Initilize & Cleanup /// <summary> /// TopViewModel のインスタンス生成テスト /// </summary> [TestMethod] [TestCategory("ViewModel")] public void TestMethod1() { try { var logger = new TestLogger(); var repository = new PhotoRepository(); var target = new TopPageViewModel(logger, repository); // ログ出力確認 Assert.IsTrue(logger.LoggedList.First().EndsWith(@"\TopPageViewModel.cs .ctor ctor")); // 読み込みアイテム確認 Assert.IsTrue(target.Items.SequenceEqual(repository.Items)); } catch (Exception ex) { Assert.Fail(ex.ToString()); } } }
ViewModel のコンストラクタを通じて必要なインスタンスを渡せるので Reflection もなしにこんなにすんなり書けちゃいます
一度テストコードを書けば回帰テストもばっちり
/// <summary> /// ログ出力サービスのテスト用スタブ /// </summary> public class TestLogger : ILogger { /// <summary> /// 出力ログ情報リスト /// </summary> public List<string> LoggedList { get; set; } /// <summary> /// コンストラクタ /// </summary> public TestLogger() { this.LoggedList = new List<string>(); } /// <summary> /// ログを出力します /// </summary> /// <param name="message">出力メッセージ</param> /// <param name="filePath">呼び出し元ファイルパス</param> /// <param name="name">呼び出し元メンバー名称</param> /// <param name="line">呼び出し元行番号</param> public void WriteLog(string message, [CallerFilePath] string filePath = null, [CallerMemberName] string name = null, [CallerLineNumber] int line = -1) { this.LoggedList.Add(string.Format("{0} {1} {2}", filePath, name, message)); } }
実際にファイル出力をしたりされると単体テストがやりにくくなるので、ログ出力のサービスはテスト用にインタフェースを実装したものに差し替えています
こういったテスト場面限定の差し替えがしやすいことも便利!