ViewModel をテスト可能にするためにファイルストリームを活用するパターンを考察

一般的に(?)MVVM パターンを使うと xUnit が作りやすい。主に Model やロジックに対して xUnit を適用すると TDD 的にあとあと楽なのですが、もう一歩進んで ViewModel にも xUnit を適用してみましょう、という話の続きです。
まぁ、普通にオブジェクト指向のクラスを作っている中で、ある程度隠蔽化されていれば xUnit を使うのもそう難しくはないんですけどね。ただ、実務的にやってみると GoF の組み合わせパターンを使ったときに xUnit の適用どころが難しかったり、最初の簡単なロジックは xUnit を使っていたけど、だんだん拡張していくうちに xUnit が使えなくなってしまったという場合もよくあります。そうそう。プロジェクトが進む中で結合テストをしていて、あまりにも設定がややこしくなってしまった xUnit のコードはばっさり捨ててしまっても構いません。本来ならば、xUnit がきちんと動く中でクラスを拡張するのがベストなのですが、時間的な制約や他社的な制約などがあってなかなか理想通りにはいきません。ならば、ある程度、xUnit で品質が保たれているであろう基本的なクラスであると「信じて」、複雑怪奇になってメンテできなくなってしまった(主に設定関係で動かなくなってしまった)テストクラスを捨ててしまうのも作業的な効率化です。あえて言えば、そのときにいくつか正しく動くパターンを残しておくのがベターです。チェックしておきたい基本的なテストコードだけ残して、あとはばっさりとコメントアウトしてしまいます。

ファイルを扱うときの ViewModel は?

MVVM パターンを使うときには、View と Model との分離が目的であり、ロジックを View に置いたり ViewModel に置いたりしてコード量を調節します。でもって、Model は単なる POJO なデータクラスであったり、Entitiy Framework のようにデータベースに接続したりする訳ですが、永続的なストレージとしては「ファイル」もあたったりします。ファイルアクセスは設定を保存したり画像ファイルを読み込んだりします。時には XML をアクセスすることもありますが、これはデータベースと同じように扱ってもよいでしょう。どちらにせよ、何らかの形で「データ」が、Model と ViewModel の間でやり取りをすることになります。

このとき、ファイルオープンをする機能は ViewModel にあったほうがいいのか否か、ということを ViewModel に xUnit を適用するという観点から考察してみます。と言いますか、すでに考察した後なので、結論から言うと、ViewModel に xUnit を適用するためには「オープン済みのファイルストリーム」を使うのが良いようです。

OpenCCPM を作っていく中で、各タスクをファイルに保存して復元するという機能を実装してみました。データ自体は XML でシリアライズすればよいので、非常に簡単です。プロトタイプは WPF で作っているので一発で保存/復元ができるので便利ですよね。と思っていたのです。が、これを Xamarin.Forms に移植しようとしたときに考え込んでしまいました。そもそものファイルの保存先は、iOS/Android で異なるし、Surface で使えるようにするため UWP アプリにするときは Storage を使わないといけません。ってことは、ファイルアクセス自体は、実行環境依存になるのです。WPF の場合は System.IO でファイルオープンができるけど、UWP は Storage を通さないと駄目というアレですね。となると、環境依存部分を含んだまま ViewModel を作ると xUnit が結構面倒なことになります。そう環境ごとに異なった xUnit を用意しないといけないからです。

実は、データベースを扱う EF のときにも同じことが言えて、WPF 等を使って SQL Server を扱うときは EF として統一して使えるのですが、ASP.NET Core を使って .NET Core 上で作ろうとすると途端におかしなことになります。.NET Core でも SQL Server を使えば EF が作れますが、じゃあ MySQL の場合はどうなのか、SQLite の場合は?そもそも、独自の軽いDBを作ったときにはどうなのか、という問題が残ります。

となれば、ViewModel や Model から実行環境依存な部分を取り除いてしまうのがベターですよね。特にファイルオープンやクローズやファイル検索などの環境依存なところを ViewModel から取り除いてしまったほうが、統一的な xUnit が作れそうです。

System.IO.Stream を使って環境依存部分を外へ追いやる

一例を示します。ViewModel が Model を抱えるようにして View にアクセスするパターンです。Model 用のデータが二重化してしまっているのが面倒な感じなのですが、永続化するときに Model を一気にシリアライズ化できるのでこれはこれで便利なのです。

public class TaskCanvasViewModel : BindableBase
{
    CcpmModel _model;
    public TaskCanvasViewModel(CcpmModel model)
    {
        _model = model;
    }

    public ObservableCollection<CcpmTask> Items
    {
        get { return _model.Tasks; }
    }

    TaskViewModel _cur;

    public TaskViewModel Current
    {
        get { return _cur; }
        set { this.SetProperty(ref _cur, value); }
    }

~省略~

    public bool Save(System.IO.Stream st)
    {
        return _model.Save(st);
    }
    public bool Load(System.IO.Stream st)
    {
        _model.Load(st);
        return true;
    }
}

永続化のメソッド(Save/Load)は、ファイルそのものを扱うのではなくてストリームを扱います。.NET Framework の場合は、System.IO.Stream がストリームの基底クラスになっています。Java とか他の言語でも同じパターンが使えます。
ストリームはオープン済みなので、ファイルがないとか権限でアクセスできないとかのエラーはありません。また、ファイルストリームだけではなくメモリストリームや文字列のストリームも使えるので汎用性が高いのです。

Model クラスではシリアライズ機能を使ってストリームに対して読み書きをします。このあたりは、WPF だけではなくて、Xamarin.iOS/Android でも共有できるところです。

public class CcpmModel
{
    private CcpmTaskCollection _tasks = new CcpmTaskCollection();

    public CcpmTaskCollection Tasks
    {
        get { return _tasks; }
    }

    // *************************************************
    // 永続化機能
    // *************************************************
    /// <summary>
    /// XML形式で保存する
    /// </summary>
    /// <returns></returns>
    public bool Save(Stream st)
    {
        // 先頭に戻す
        st.SetLength(0);
        // カレントフォルダに保存
        var se = new System.Xml.Serialization.XmlSerializer(typeof(CcpmModel));
        var sw = new System.IO.StreamWriter(st);
        se.Serialize(sw, this);
        return true;
    }
    /// <summary>
    /// XML形式から復元する
    /// </summary>
    /// <returns>読み込んだ CcpmModel オブジェクトを返す</returns>
    public CcpmModel Load(Stream st)
    {
        var se = new System.Xml.Serialization.XmlSerializer(typeof(CcpmModel));
        var sr = new System.IO.StreamReader(st);
        var obj = se.Deserialize(sr) as CcpmModel;
        CopyFrom(obj);
        return this;
    }
    public static CcpmModel LoadXml(Stream st)
    {
        var se = new System.Xml.Serialization.XmlSerializer(typeof(CcpmModel));
        var sr = new System.IO.StreamReader(st);
        var obj = se.Deserialize(sr) as CcpmModel;
        return obj;
    }

    /// <summary>
    /// 自モデルにコピーする
    /// </summary>
    /// <param name="src"></param>
    /// <returns></returns>
    public CcpmModel CopyFrom(CcpmModel src)
    {
        this.Tasks.Clear();
        src.Tasks.All(x => { this.Tasks.Add(x); return true; });
        return this;
    }

    // *************************************************
    // CURD 機能を実装する
    // *************************************************
~~省略~~
}

こうすると、ViewModel と Model が実行環境に依存しないので、PCL 化ができます。WPF/UWP/Xamarin.iOS/Android/.NET Core の5種類の環境で利用できるようになります。

これは永続化の機能部分をストリームとして抽象化していることで得られる利益です。

ファイルアクセスの環境依存部分は何処に作るのか?

さて、ひとつ大きな問題が残っています。具体的にファイルアクセスをする部分(ファイルオープンをしてストリームを作成する部分)は何処に持っていけばよいでしょうか?ViewModel と Model は PCL 化が目的でもあるので、ここにファイルアクセスのような環境依存部分を持ってくるとはできません。パターン的に、ViewModel, Model に置くことは可能です。その場合は、環境依存のための ViewModel, Model を別途作ることになります。

環境依存用の Model を作ってもよいのですが、ちょっと考えてみましょう。そもそも、View 自体は環境依存ではないでしょうか?WPFのXAMLや、Xamrin.Android の UI や、共通機能としての Xamarin.Forms の XAML などは、それぞれの実行環境に依存しています。ということは、View のほうに持って行っても良いですよね。

ということで、View のコードビハイドに持って行ってしまいます。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }
~~略~~
    /// <summary>
    /// タスクカードを保存
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void clickSave(object sender, RoutedEventArgs e)
    {
        var st = System.IO.File.OpenWrite(OpenCcpm.Models.CcpmModel.SETTING_FILENAME);
        this.tc.Save(st);
        st.Close();

    }
    /// <summary>
    /// タスクカードを読込
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void clickLoad(object sender, RoutedEventArgs e)
    {
        var st = System.IO.File.OpenRead(OpenCcpm.Models.CcpmModel.SETTING_FILENAME);
        this.tc.Load(st);
        st.Close();

    }
}

これは、相当異論があると思うし MVVM パターンなのにコードビハイドを使わないと駄目なのはおかしい、と思うでしょうが、ViewModel を xUnit でテストができるようにするには、この方法がいいんですよね。

ちなみにテストコードはこんな感じになります。

/// <summary>
/// モデルを保存する
/// </summary>
[TestMethod]
public void TestSave()
{
    // 初期化
    var model = new CcpmModel();
    // 2つタスクを追加する
    model.Create("T001").Title = "最初のタスク";
    model.Create("T002").Title = "次のタスク";

    var st = System.IO.File.OpenWrite(CcpmModel.SETTING_FILENAME);
    var ret = model.Save(st);
    st.Close();
    Assert.AreEqual(true, ret);
    Assert.AreEqual(true, System.IO.File.Exists(CcpmModel.SETTING_FILENAME));
}

/// <summary>
/// モデルを読み込む
/// </summary>
[TestMethod]
public void TestLoad()
{
    // 初期化
    var model = new CcpmModel();
    // 2つタスクを追加する
    model.Create("T001").Title = "最初のタスク";
    model.Create("T002").Title = "次のタスク";
    var st = System.IO.File.OpenWrite(CcpmModel.SETTING_FILENAME);
    model.Save(st);
    st.Close();
    // XMLから復元
    st = System.IO.File.OpenRead(CcpmModel.SETTING_FILENAME);
    model.Load(st);
    st.Close();
    Assert.AreEqual(2, model.Tasks.Count);
    Assert.AreEqual("最初のタスク", model.Tasks[0].Title);
    Assert.AreEqual("次のタスク", model.Tasks[1].Title);
}

実際は、もうちょっと進めてから考えることになりますが(多人数で分担するとか、Viewの規模の問題とか)、いまのところ View に近いところが一番環境依存しているところなので、そこに Save/Load 機能を置いています。

まあ、環境依存のため(設定ファイル絡み)の Model をひとつ用意して、そこへ prototype パターン的にアクセスするのがよりベターかなと思いますが、ひとまず、これで。

カテゴリー: 開発, C#, TDD パーマリンク