ささいなことですが。

Windowsアプリテスト自動化ライブラリFriendly開発者の日記です。

Friendly ハンズオン 10 .Netアプリで出てくるネイティブウィンドウ対応

前回Win32用の上位ライブラリのNativeStandardControlsを使う練習でした。
今回は、さらに実践的に.Netで出てくるWin32のWindowに対応してみます。
先にネタをバラしておきます。
操作方法は書きますが、最終的にはメッセージボックス以外は操作しない方がいいです。
???じゃあどうするかって?
それは、最後まで読んでみてくださいw

.Netなのに、出てくるWin32のウィンドウって何?

よく使う機能で3つあります。

・メッセージボックス
f:id:ishikawa-tatsuya:20150108224628p:plain
・ファイルダイアログ
f:id:ishikawa-tatsuya:20150108224635p:plain
・フォルダダイアログ
f:id:ishikawa-tatsuya:20150108224650p:plain
これらはWinFormsだけでなくWPFでも同様です。
使うときは、.Netのクラスが用意されていますが、あれはネイティブの処理をラップしているだけで、出てくるダイアログやその実装はネイティブなのですね。

ハンズオンの練習用プロジェクト

こちらからダウンロードお願いします。
Ishikawa-Tatsuya/HandsOn10 · GitHub
ダウンロードしたらビルドして、起動してみてください。
f:id:ishikawa-tatsuya:20150108225006p:plain
それぞれのボタンを押すと上記のダイアログが出ます。
そして、ファイルダイアログとフォルダダイアログは、それぞれ選択したパスがタイトルに設定されます。
f:id:ishikawa-tatsuya:20150108225400p:plain

テストプロジェクトも参照と最初の画面のボタン取得まで終わっています。このコードに書き足していってください。

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Codeer.Friendly;
using Codeer.Friendly.Dynamic;
using Codeer.Friendly.Windows;
using System.Diagnostics;
using System.Windows.Forms;
using Ong.Friendly.FormsStandardControls;
using Codeer.Friendly.Windows.Grasp;
using Codeer.Friendly.Windows.NativeStandardControls;
using System.Runtime.InteropServices;
using System.Linq;

namespace Test
{
    [TestClass]
    public class NetNativeWindowTest
    {
        WindowsAppFriend _app;
        WindowControl _form;
        FormsButton _buttonMessageBox;
        FormsButton _buttonFile;
        FormsButton _buttonFolder;

        [TestInitialize]
        public void TestInitialize()
        {
            _app = new WindowsAppFriend(Process.Start("FormsTarget.exe"));
            _form = new WindowControl(_app.Type<Application>().OpenForms[0]);
            _buttonMessageBox = new FormsButton(_form.Dynamic()._buttonMessageBox);
            _buttonFile = new FormsButton(_form.Dynamic()._buttonFile);
            _buttonFolder = new FormsButton(_form.Dynamic()._buttonFolder);
        }

        [TestCleanup]
        public void TestCleanup()
        {
            Process.GetProcessById(_app.ProcessId).CloseMainWindow();
        }
    }
}

ハンズオン開始

メッセージボックス

これは、前回もやりましたね。頻出すぎるので、ラッパークラスを用意しています。それを使ってください。

[TestMethod]
public void TestMessageBox()
{
    var async = new Async();
    _buttonMessageBox.EmulateClick(async);

    //メッセージボックスを取得してラップ
    var msg = new NativeMessageBox(_form.WaitForNextModal());

    //メッセージを取得
    Assert.AreEqual("Msg", msg.Message);

    //テキストからボタンを検索して押す
    msg.EmulateButtonClick("OK");

    //非同期処理の完了待ち
    async.WaitForCompletion();
}

ファイルダイアログ

これは、面倒なコードになりますね。なのでファイルダイアログを操作するコードはOpenFileという関数にしました。
ファイルダイアログは画面要素の特定が非常に面倒ですね。「開くボタン」は文字列から取得できるとして、パスを入力するコンボボックスはどうしましょう?コンボボックスは二つありますね。座標で比較して下の方にしますか。

[TestMethod]
public void TestOpenFileDialog()
{
    var async = new Async();
    _buttonFile.EmulateClick(async);

    string myPath = GetType().Assembly.Location;

    //ファイルを開く処理
    OpenFile(_form.WaitForNextModal(), myPath);

    //非同期処理の完了待ち
    async.WaitForCompletion();

    //フォームのテキストをチェック
    Assert.IsTrue(string.Compare(myPath, (string)_form.Dynamic().Text, true) == 0);
}

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect);

static void OpenFile(WindowControl fileDialog, string path)
{
    //ボタン取得
    var buttonOpen = new NativeButton(fileDialog.IdentifyFromWindowText("開く(&O)"));

    //コンボボックスは二つある場合がある。
    //下の方を採用。
    var comboBoxPathSrc = fileDialog.GetFromWindowClass("ComboBoxEx32").OrderBy(e =>
    {
        RECT rc;
        GetWindowRect(e.Handle, out rc);
        return rc.Top;
    }).Last();
    var comboBoxPath = new NativeComboBox(comboBoxPathSrc);

    //パスを設定
    comboBoxPath.EmulateChangeEditText(path);

    //開くボタンを押す
    buttonOpen.EmulateClick();
}

OpenFileは汎用的な関数にしましたので、もし操作する場合は、これを自分のプロジェクトにコピーして使ってください。

フォルダダイアログ

次はフォルダダイアログです。これもややこしいのでフォルダダイアログの操作はSelectFolderと言う関数にまとめてみました。

[TestMethod]
public void TestFolderDialog()
{
    var async = new Async();
    _buttonFolder.EmulateClick(async);

    //フォルダ選択処理
    SelectFolder(_form.WaitForNextModal(), @"デスクトップ", @"PC", @"Windows (C:)", @"Program Files");

    //非同期処理の完了待ち
    async.WaitForCompletion();

    //フォームのテキストをチェック
    Assert.IsTrue(string.Compare(@"C:\Program Files", (string)_form.Dynamic().Text, true) == 0);
}

static void SelectFolder(WindowControl folderDialog, params string[] nodes)
{
    //ツリーは一つしかない
    NativeTree tree = new NativeTree(folderDialog.IdentifyFromWindowClass("SysTreeView32"));
    //OKというウィンドウテキストもひとつだけ
    NativeButton buttonOK = new NativeButton(folderDialog.IdentifyFromWindowText("OK"));

    //ツリーのノードを一つずつ取得して選択
    for (int i = 1; i <= nodes.Length; i++)
    {
        //フォルダツリーはフォルダを非同期に検索している(と思う)ので
        //期待のアイテムが表示されていない場合がある
        //表示されるまで待つ
        var itemPath = nodes.Take(i).ToArray();
        IntPtr item = IntPtr.Zero;
        while (item == IntPtr.Zero)
        {
            item = tree.FindNode(itemPath);
        }

        //展開と選択
        tree.EmulateExpand(item, true);
        tree.EmulateSelectItem(item);
    }

    //OKボタンを押す
    buttonOK.EmulateClick();
}

フォルダダイアログは画面要素の特定は何とかなりそうです。ツリーは一つしかないのでWindowクラスから特定できます。ボタンもWindowテキストから特定できますね。
でも、コードを見るとツリーのノード選択のコードが変ですね。何をやっているのでしょうか?
これは、ノードが表示されるのを待ち合わせているのです。
フォルダダイアログのツリーは展開した瞬間ではなく、若干遅延して子ノードを表示しています。ファイルアクセスなので仕方ないですね。なのでノードが取得可能な状態になるのを待っているのです。
SelectFolderも汎用的に作ってみたので、操作する場合はコピーして使ってください。

難しい?

はい。ファイルダイアログとフォルダダイアログはかなり難易度高いですね。しかも、これらはOSや個人設定によりデザインが変わってきます。そういうのは運用時のデメリットになってしまいます。
僕もメッセージボックスはラッパークラスを提供していますが、これらのダイアログは提供していません。何か問題発生しそうだからです。(各プロジェクトで作ってもらった場合は何とかなりますが、ライブラリとして公開して広く使われると、問題発生時に修正が困難なのです。)

何で難しい?

ネイティブだからでしょうか?
違います。
内部実装を知らないからです。
例えば、自分が作ったダイアログなら画面要素の取得もダイアログIDからできます。そして、今後そこが変わらないように調整できますし、変わったとしてもどう変えたか把握しているのでテストに反映も出来ます。内部実装だとしても運用的にはpublicで制御可能なものなのです。
しかし、他社(MSとか)の作ったものは、本当にpublicな仕様でないと、それで正しいのかわからないし、いつ変えられても文句は言えません。特にフォルダダイアログの例の「多分非同期で・・・」みたいなのは単なる推測です。
もし、これらを操作するとしても、最悪この辺りの微妙さを理解して、何か問題が発生したときの影響を最小限に抑えるような設計にする必要があります。
これは、Friendlyを使う場合に限らず自動化全般に言えることです。

*正確にはメッセージボックスも内部実装は知りません。しかし、難易度、使用頻度から考えて提供することにしました。

他人の作ったダイアログは無理に操作しなくてもいい。

結局ですね、
GUIの詳細をテストしたいのではない。結合したシステムをテストしたいのだ。」
と言うことなのですよ。
まあ、この例ではアレですけど、ホントのテストケースを作成する場合は、フォルダダイアログの振る舞いをテストしたいのではなくて、ダイアログでフォルダを選んだ先に実行される処理をテストしたいのですよね。フォルダダイアログは他人が作ったものです。まあテストする必要がないとは言いませんが、自動化して回帰検査で毎日テストしてやる価値はないですね。最後に人間が一回見れば済むことです。
と言うことで、対象アプリの「FolderBrowserDialogボタン」を押した処理のコードを見てみましょう。

private void ButtonFolderClick(object sender, EventArgs e)
{
    //フォルダダイアログの操作は日々テストする程のものではない。
    string path = string.Empty;
    using (var dlg = new FolderBrowserDialog())
    {
        if (dlg.ShowDialog() != DialogResult.OK)
        {
            return;
        }
        path = dlg.SelectedPath;
    }

    //ここから先をテストしたい
    ExecuteFilePathCore(path);
}

private void ExecuteFolderPathCore(string path)
{
    //本当はもっと難しい処理
    Text = path;
}

結局本来であれば、ExecuteFolderPathCore以降の処理がテストできればいいんですよね。なので、これを呼び出すようにしてやりましょう。

[TestMethod]
public void TestExecuteFolderPathCore()
{
    _form.Dynamic().ExecuteFolderPathCore(@"C:\Program Files");
    //フォームのテキストをチェック
    Assert.IsTrue(string.Compare(@"C:\Program Files", (string)_form.Dynamic().Text, true) == 0);
}

この辺がFriendlyの醍醐味です。インターフェイスコンポーネントテストや単体テストのように自由に選択、変更できるので、効率の悪い箇所を削って費用対効果の高い自動化が実現できるのです。
こちらは、そんな感じの話のスライドです。

次回はWPFの上位ライブラリやります。