内藤 裕二/ 2018年 11月 23日/ 技術

TL;DR

Codeer FriendlyでWPFのGUIテストを行うサンプルと、軽い解説をします。

参照URL

概要

Friendlyは、株式会社Codeer (コーディア) が公開しているGUI操作用のライブラリです。
Friendlyを使用すると、ViewとModelが分離できていないようなプログラムでもユニットテストを記述することができます。
nugetでインストールできますので、導入は簡単です。

解説

サンプルコード

サンプルコードは、下記よりダウンロードできます。

https://github.com/rot-z/CodeerFriendlySample

試験対象のプログラム

下記のようなソフトウェアキーボードっぽいプログラムを用意しました。

サンプルアプリケーション

Input Modeのコンボボックスを変更すると、下部のボタンがひらがな/カタカナに切り替わります。
ひらがな/カタカナがキャプションになっているボタンを押下すると、Input Textに当該文字が追加されます。

XAML構成

ひらがな用のGridとカタカナ用のGridを同一位置に配置しています。
Input ModeのコンボボックスのSelectedIndexをトリガにして、ひらがな用のGridとカタカナ用のGridを切り替えています。

テストコード

Friendlyでテストコードを記述する際のお約束については、かずきさんのブログが詳しいです。

サンプルコードでは、CodeerFriendlySampleTestプロジェクトがテストプロジェクトになります。

以下、つたない理解ですが、自分の理解をまとめます。

Friendly全体に関しては、下記のような感じです。

  • Friendlyではアタッチしたプロセス内のすべてのアセンブリを取得して操作できる
  • 基本的にすべてのオブジェクトはAppVarというdynamicをラップしたクラスのインスタンスとして取得できる
  • AppVarは名前ベースで当該オブジェクトが持つすべてのメソッド・プロパティにアクセスできる
  • Win32, Forms, WPFそれぞれについて、主要なコントロールを操作しやすくするライブラリを公式に提供してくれている(RM.Friendly.WPFStandardControls等)

Friendlyを使用したユニットテストに関しては、下記のような感じです。

  • TestInitializeでテスト対象のアプリを起動してアタッチ, TestCleanupでアプリを停止する
  • 対象アプリのメインウィンドウは取得できるが、ウィンドウ内のコントロールに対してAppVar経由でアクセスするのはとても冗長になる
    なので、操作対象のコントロールを取得して操作用のクラス(WPFButton等)のインスタンスをあらかじめ取得しておく
  • 操作用のクラスを取得する部分はテストと関係ない部分なので、テストドライバとして別クラスにまとめておく

TestInitializeおよびTestCleanup

        [TestInitialize]
        public void TestInitialize()
        {
            // Execute target process and attach
            var path = System.IO.Path.GetFullPath("CodeerFriendlySample.exe");
            _app = new WindowsAppFriend(Process.Start(path));
            var w = _app.IdentifyFromTypeFullName("CodeerFriendlySample.MainWindow");
            _drv = new TestDriver(w);
        }

        [TestCleanup]
        public void TestCleanup()
        {
            // SoftwareKeyboardTestAppの終了
            Process process = Process.GetProcessById(_app.ProcessId);
            _app.Dispose();
            process.CloseMainWindow();
        }

TestIntializeは、テスト対象のアプリを起動しアタッチした後、メインウィンドウを取得します。
取得したメインウィンドウを元にテストドライバを生成し、保持します。

テストドライバ

最初にFriendlyを使用する際に、ちょっとハードル感の高い部分だと思います。

    public class TestDriver
    {
        public WindowControl MainWindow { get; private set; }
        public IWPFDependencyObjectCollection<DependencyObject> LogicalTree { get; }
        public WPFTextBox txtInputText { get; private set; }

        public WPFComboBox cmbMode { get; private set; }
        public WPFGrid grdHiragana { get; private set; }
        public WPFGrid grdKatakana { get; private set; }

        public TestDriver(WindowControl w)
        {
            MainWindow = w;
            LogicalTree = w.LogicalTree();

            cmbMode = new WPFComboBox(LogicalTree.ByType<ComboBox>().Single());
            txtInputText = new WPFTextBox(LogicalTree.ByType<TextBox>().Single());
            grdHiragana = new WPFGrid(w.AppVar["FindName"].Invoke("grdHiragana"));
            grdKatakana = new WPFGrid(w.AppVar["FindName"].Invoke("grdKatakana"));
        }

        public WPFButtonBase GetButton(string buttonCaption)
        {
            var btn = LogicalTree.ByType<Button>().ByContentText<Button>(buttonCaption).Single();
            if (btn == null) return null;
            return new WPFButtonBase(btn);
        }

    }

ユニットテストでアクセスするコントロール(UIElement)をFriendlyの操作用クラスでラッピングして保持しているだけです。
結局のところ、やりたいことは下記を満たすことだけです。

操作用のクラスを取得する部分はテストと関係ない部分なので、テストドライバとして別クラスにまとめておく

テストドライバのプロパティとして、必要な分のコントロール操作クラスのインスタンスを公開します。

Windowオブジェクトの操作クラスであるWindowControlクラスには、コントロールをIEnumerable的に扱えるLogicalTreeメソッドがあるので、kコンストラクタ内部で目的のコントロールをがしがし保持します。
ユニットテストコードからは、このテストドライバのインスタンスを通して、テスト対象アプリのコントロールを操作することになります。

ユニットテスト

サンプルには2つのユニットテストを記載していますが、ここでは1つだけ例示します。

[TestMethod]
public void TestHiragana()
{
    // txtInputTextをクリア
    _drv.txtInputText.EmulateChangeText("");

    // ひらがなを選択
    _drv.cmbMode.EmulateChangeSelectedIndex(0);

    // ひらがなが表示されるのを待つ
    while(_drv.grdHiragana.Visibility != System.Windows.Visibility.Visible)
    {
        System.Threading.Thread.Sleep(10);
    }

    // ひらがなが表示、カタカナが非表示
    Assert.AreEqual<System.Windows.Visibility>(_drv.grdHiragana.Visibility, System.Windows.Visibility.Visible);
    Assert.AreEqual<System.Windows.Visibility>(_drv.grdKatakana.Visibility, System.Windows.Visibility.Collapsed);

    // 「あ」入力
    var btnA = _drv.GetButton("あ");
    btnA.EmulateClick();
    Assert.AreEqual<string>(_drv.txtInputText.Text, "あ");

    // 「い」入力
    var btnI = _drv.GetButton("い");
    btnI.EmulateClick();
    Assert.AreEqual<string>(_drv.txtInputText.Text, "あい");
}

普通のGUIの試験項目と同じような感覚で、ユニットテストコードが記載できます。
(テストの内容がこんなもんで良いのか、という話はひとまず置いておいてください・・・)

はまりどころ

テストドライバ書くのが大変

これが一番大変です。
所詮はテストコードなので、動けばいいや、くらいの気持ちで書いた方が良いかもしれません。
公式には支援ツールがありそうなので、それを使えば省力化できるかもしれません。

コントロールの状態変化のタイムラグに注意

GUIの試験なので、コントロールの状態変化のタイムラグがあります。
今回のサンプルだと、コンボボックスの選択を変更したタイミングでGridのVisibilityが変化しますが、EmurateChangeSelectedIndexが完了したタイミングではまだGridのVisibility変化しきっていない時があったりします。
何もしないとユニットテストの再現性が落ちてしまうので、とりあえず、Visibility変化を待ち受けるコードを入れて対処しています。

結論

仕様書のない既存のアプリケーションの保守を行う場合でも、少しずつGUIベースのユニットテストを書き足していく事で、変更に対する耐性を上げていくことができるようになると思います。
Friendlyを使用して、デグレの発生におびえない日々が来ると良いな、と思います!