UIAutomationで.Net製デスクトップアプリのGUIコンポーネントの自動制御を試みるまでのハートフルストーリー

今日のテーマ

  • .Netで開発されたデスクトップアプリケーションの結合テストを自動化したい
  • 検証対象はGUIベースのシステムなので、ビュー層(画面)の諸制御は避けて通れない
  • つまりバックエンドのUTほど話は簡単ではない

問題点

問題点はいろいろあるけどとりあえずこれ。

一文目は特にプログラムのテスト全般に対する一般論であることに注意してね。

Webアプリ界隈ではSeleniumという自動化ツールがそこそこ定着していますが、デスクトップアプリだとまだまだGUIの自動テストの決定打がない印象です*1

というわけで、同業者の知恵を借りることにしました。

はぅ君の回答

なるほど AutomationElement ……。

そんなべんりなものがあったとは……。

今日やること

とりあえず以下の検証作業を自動化してみようと思います。ほんと最低限です。

  • Windowsに標準でインストールされている電卓アプリを起動する
  • Cボタンで電卓をクリアする
  • 7ボタンを7回連打する
  • 電卓の表示結果が7777777であることを確認する
  • 電卓を終了する

なお、本記事の執筆にあたり使用した環境は、Surface Pro 3 (Windows 8.1 Pro 64ビット版) + Visual Studio Express 2013 for Windows Desktop です。

じゅんび

「コンソールアプリケーション」のC#プロジェクトを新規作成し、以下2つのライブラリを参照に追加します。

  1. UIAutomationClient
  2. UIAutomationTypes

f:id:tercel_s:20150429155350p:plain

コーディング

以下のコードを書きます。

電卓を起動して「7」ボタンを7回連打して結果を取得する

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Windows.Automation;

namespace UIAutomationSample
{
    class Program
    {
        private static readonly string PROCESS_NAME = @"calc";
        private static readonly int DEFAULT_WAIT_TIME = 1000;

        private static AutomationElement mainForm;

        static void Main(string[] args)
        {
            Process process = Process.Start(PROCESS_NAME);
            try
            {
                // 電卓を起動します
                Thread.Sleep(DEFAULT_WAIT_TIME);
                mainForm = AutomationElement.FromHandle(process.MainWindowHandle);

                // クリアボタン押します
                var btnClear = FindElementsByName(mainForm, "クリア").First()
                    .GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
                btnClear.Invoke();

                // 「7」ボタンを7回押します
                var btn7 = FindButtonsByName(mainForm, "7").First()
                    .GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;

                foreach (var _ in Enumerable.Repeat(0, 7))
                {
                    btn7.Invoke();
                }

                // 電卓に表示されている計算結果を、標準出力に表示します
                const string DISPLAY_AREA_ID = "150";
                var resultArea = FindElementById(mainForm, DISPLAY_AREA_ID);

                // 結果が想定通りかをチェックします
                const string EXPECTED_VALUE = "7777777";
                string actualValue = resultArea.Current.Name;

                Console.WriteLine("期待値 {0} に対して、結果値は {1} です", EXPECTED_VALUE, actualValue);
                Console.WriteLine("テスト結果は {0} です", EXPECTED_VALUE == actualValue ? "OK" : "NG");
            }
            finally
            {
                process.CloseMainWindow();
            }
        }

        // 指定したID属性に一致するAutomationElementを返します
        private static AutomationElement FindElementById(AutomationElement rootElement, string automationId)
        {
            return rootElement.FindFirst(
                TreeScope.Element | TreeScope.Descendants,
                new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
        }

        // 指定したName属性に一致するAutomationElementをすべて返します
        private static IEnumerable<AutomationElement> FindElementsByName(AutomationElement rootElement, string name)
        {
            return rootElement.FindAll(
                TreeScope.Element | TreeScope.Descendants,
                new PropertyCondition(AutomationElement.NameProperty, name))
                .Cast<AutomationElement>();
        }

        // 指定したName属性に一致するボタン要素をすべて返します
        private static IEnumerable<AutomationElement> FindButtonsByName(AutomationElement rootElement, string name)
        {
            const string BUTTON_CLASS_NAME = "Button";
            return from x in FindElementsByName(rootElement, name) 
                   where x.Current.ClassName == BUTTON_CLASS_NAME 
                   select x;
        }
    }
}

実行すると、電卓が勝手に起動して、ばばばっと処理が進んで結果が表示されます。

一応、電卓のオシャレな画面に表示されている「7777777」も文字列データとしてきちんと取得できているので、NUnitアサーションが使えますよ。

f:id:tercel_s:20150429154945p:plain:w150 f:id:tercel_s:20150429154951p:plain:w400

画面要素の特定

画面に配置されているコンポーネントを区別するため、内部的にIDが付されています。

ということは、コンポーネントをいじくり回したいときには該当するIDをどうにかして調べなければならないわけです。

はい、ここで電卓の表示内容を取得する部分に注目。

なんかしれっと "150" なんていうIDが指定されていますが、一体これはどこで知った情報でしょう。

// 電卓に表示されている計算結果を、標準出力に表示します
const string DISPLAY_AREA_ID = "150";
var resultArea = FindElementById(mainForm, DISPLAY_AREA_ID);


実はこれ、UIAutomation PowerShell Extensionsに同梱されているUIAutomationSpyというツールを使って調べました。

UIAutomationSpyの(最低限の)使い方

使い方はとっても簡単。

  1. UIAutomationSpyを起動して、Startボタンをクリック
  2. 調べたいコントロールにマウスを重ねる

基本的にはたったこれだけです。

すると、マウスが乗っているコントロールが薄い赤色で囲まれ、UIAutomationSpyに当該コントロールの情報が表示されます。

Get-UiaText -AutomationId ‘150’ -Class ‘Static’ -Name ‘0’ -Win32;

この -AutomationId ‘150’が、先ほどのコードに書かれていた数字の正体です。

f:id:tercel_s:20150429155000p:plain

キーボードからの文字入力


電卓では、ボタンだけでなくキー入力による操作も可能です。

キー入力による計算結果が期待に沿うものかどうかを検証するコードは以下の通りです(Mainのみ抜粋)。

        static void Main(string[] args)
        {
            Process process = Process.Start(PROCESS_NAME);
            try
            {
                // 電卓を起動します
                Thread.Sleep(DEFAULT_WAIT_TIME);
                mainForm = AutomationElement.FromHandle(process.MainWindowHandle);

                // クリアボタン押します
                var btnClear = FindButtonsByName(mainForm, "クリア").First()
                    .GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
                btnClear.Invoke();

                // キーボードから「 6 * ( 1 + 2 ) = 」を入力
                foreach (var key in new string[]{"6", "*", "{(}","1","{+}","2","{)}","=" } )
                {
                    SendKeys.SendWait(key);
                }

                // 電卓に表示されている計算結果を、標準出力に表示します
                const string DISPLAY_AREA_ID = "150";
                var resultArea = FindElementById(mainForm, DISPLAY_AREA_ID);

                // 結果が想定通りかをチェックします
                const string EXPECTED_VALUE = "18";
                string actualValue = resultArea.Current.Name;
                
                Console.WriteLine("期待値 {0} に対して、結果値は {1} です", EXPECTED_VALUE, actualValue);
                Console.WriteLine("テスト結果は {0} です", EXPECTED_VALUE == actualValue ? "OK" : "NG");
            }
            finally
            {
                process.CloseMainWindow();
            }
        }

まとめと今後の課題

一応、既存のデスクトップアプリケーションの簡単な操作と結果の取得を自動化する方法はなんとなくわかった。

ただし、無駄に凝ったUI (あらゆるマウスジェスチャを想定した作り)のアプリは簡単に制御できるのか不明。

さしあたって今後やりたいことは以下の通りです。

  • 右クリックやドラッグアンドドロップ操作を自動化する方法を調べる
  • コンボボックスの表示内容を検証する方法を調べる

*1:ちなみに弊社にも独自開発した「キーワード駆動型GUI自動テストツール」があるのですが、大人の事情により僕は使えないことになっています。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.