Help us understand the problem. What is going on with this article?

[C#] 自動UIテストで遊ぼう:生まれ変わったエッヂのはるかさんがクリスマスイブの予定を読み上げる(WinAppDriver でできること/できないこと)

祝! Chromium 版 Microsoft Edge 正式リリース(ベータ版向けから書き換えました)

自動UIテストの世界へようこそ

単体テストの自動化はかなり浸透しましたが、UIテストの自動化は、特にクライアント Windows アプリケーションにおいて、まだあまり活用されていないようです。
プログラム変更のたび、リリースのたびに繰り返されるリグレッションテストで工数削減の威力を発揮しますので、今まで触れる機会のなかった方も、楽しみながら導入を検討してみてはいかがでしょうか。

WinAppDriver(Windows Application Driver)は、Microsoft で開発されている Windows アプリケーション(UWP/Windows フォーム/WPF/Win32)のUIテスト自動化のためのサービスです。
ここでは C# から、一般的には Selenium で扱うブラウザを操作することで、WinAppDriver の限界に挑戦し、できること/できないことを明らかにしようと思います。

この記事で得られる知見

Appium + WinAppDriver での Windows アプリケーション操作

  • Selenium などから Web アプリケーションを操作する際はDOMベースで扱うところ、WinAppDriver の場合は「UIオートメーション」を通して提供された情報だけで操作します。諸事情から(例を後述します)WinAppDriver でブラウザを扱う場合、どのように対処すればいいかがわかります。
  • 難題に対処する過程で生まれた副産物として、通常の Windows アプリケーション操作でも役に立ちそうな手法やアイデアが含まれています。WinAppDriver 自体癖がありますので、困ったとき、ハマったときに見渡してみると、何かヒントが得られるかもしれません。

注意

この記事で扱っているのは、WinAppDriver の本来的な守備範囲からは少し外れる Web アプリケーションです。
Windows アプリケーションの操作はこれほどたいへんではありませんので、初めて興味を持った方は「こんなにたいへんなら」と逃げ出さないでください。
特にUI要素の特定など、ここでできないからといって C# や VB.NET で開発した Windows アプリケーションで同様のことが実現できないわけではありません。

Chromium ベースの新 Microsoft Edge

  • UIオートメーションによる操作
  • 音声読み上げの新しくなった点

前提となる知見

Appium + WinAppDriver でのブラウザ操作

  • Inspect や WinAppDriver UI Recorder で AutomationId を見つけても By.IdBy.XPath の属性指定で見つけられません。
  • すべての DOM 要素が WindowsElement として提供されるわけではありません。サンプル的にHTMLと照合してみた限りでは、以下のように対応していました。
DOM 要素 WindowsElement 要素
div(子要素あり) TagName: Group
div(テキストコンテンツ) TagName: Text
span 取得できない
  • その他いろいろ。Selenium の世界では当たり前にできる操作も、WinAppDriver では苦労したりできなかったりします。

シナリオ

  1. Google カレンダーにログインします。
  2. 10年前の12月24日のページを開きます。
  3. 年月を読み上げます。
  4. 当日(イブと言いつつ午前中から)に登録された全イベントのダイアログを順に開き、タイトルを読み上げていきます。
  5. 「2.」から「4.」を今年まで繰り返します。

開発環境

開発プラットフォーム

  • .NET Core 3.0 - 3.1 または .NET Framework 4.5.2 以降
  • MSTest(Visual Studio 付属)

※.NET 以外に Java や Python でも同様のことが実現できると思います。

開発言語

C#

依存ライブラリ

  • Install-Package Appium.WebDriver -Version 4.1.1(以下を含む)
    • Selenium.WebDriver -Version 3.141.0
    • Selenium.Support -Version 3.141.0

実行環境

OS

Windows 10(言語設定:日本語)

Microsoft Edge(Chromium エンジン)

動作確認バージョン:v79.0.309.65 (公式ビルド)
※当初ベータ版でしたが、正式リリースに伴い、コードを書き替えました。

  • 読み上げの音声に「Microsoft Haruka Online - Japanese (Japan)」を選択する(参考)。
  • ほかの Edge ウィンドウは閉じておく(理由は後述します)。

なぜ Edge? しかも Chromium エンジンの

年明け(2020年)1月15日に、Chromium エンジンを搭載した新しい Microsoft Edge1安定版がリリースされます(→ました)。

Edge にはもともと標準で読み上げ機能がありましたが、新しい Edge では選択部分のみを読み上げることができるようになりました。
Microsoft Azure のAIプラットフォーム Cognitive Services が提供するクラウドベースの「深層ニューラルネットワークによって強化された音声」も新たに追加されたということです(Microsoft Edge Blog)。

まだβ版ですが、試したところ動作は安定しているようでした。

WinAppDriver (Windows Application Driver) v1.1

  • Windows の「開発者モード」を有効にしたうえで、管理者権限で起動しておく。

なぜ Selenium WebDriver でなく WinAppDriver?

  • Edge の音声読み上げが実行できる。
    • Selenium では "element not interactable" となってしまう。
  • Edge 通常(非オートメーション)起動時のユーザープロファイル(保存パスワードなど)をそのまま使用できる。
    • Selenium から EdgeDriver で起動すると、Chromium エンジンの Edge はオートメーションモードで動作し2、既定で一時的なプロファイルが使用される。
  • バージョンごとに対応する WebDriver を用意(確認してダウンロード、手動配置)する必要がない。

Google カレンダー

  • Chromium エンジンの Edge で自動ログインできる状態にしておく。
  • 左サイトのメインメニューは閉じておく。
  • 「通知の表示」ダイアログが出たら閉じておく。

テストコード

では、コードを見ていきましょう。

最初に主たるテストメソッドです。
詳細はここから呼ばれるメソッドごとに解説していきますので、目次だと思ってください。

テストメソッド
[TestMethod]
public void ReadAloudChristmasEveEventsInDecade()
{
    // Edge 用の WindowsDriver
    var driver = CreateWindowsDriver();

    // 過去10年間 + 今年
    foreach (int year in Enumerable.Range(DateTime.Today.Year - 10, 10 + 1))
    {
        var eveDate = new DateTime(year, 12, 24);

        // Google カレンダーで該当年12月24日のページを開く
        Navigate(driver, $"https://calendar.google.com/calendar/r/day/{eveDate:yyyy/M/d}");

        // ヘッダの年月を読み上げる
        var yearMonthElement = WaitUntilElementToBe(driver, By.Name($"{eveDate:yyyy'年' M'月'}"), TimeSpan.FromSeconds(5));
        ReadAloudUnselectableText(driver, yearMonthElement, TimeSpan.FromSeconds(5.5));

        // 全イベント(予定/リマインダー)
        foreach (var eventButton in FindEventButtons(driver))
        {
            // イベントのタイトルを読み上げる
            ReadAloudEventTitle(driver, eventButton);
        }
    }

    // Edge を閉じる
    driver.Dispose();
}

流れはシナリオのとおりです。

driverDispose で Edge ウィンドウが閉じられます。
using で囲っていないのは、エラーが発生したとき開いたままにして原因を追うためです。

Edge 用の WindowsDriver を作成する

private static WindowsDriver<WindowsElement> CreateWindowsDriver()
{
    var remoteAddress = new Uri("http://127.0.0.1:4723");

    var options = new AppiumOptions();

    // Chromium 版 Edge のEXEパス
    options.AddAdditionalCapability("app", @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe");

    return new WindowsDriver<WindowsElement>(remoteAddress, options);
}

WinAppDriver はローカル実行です。
AddAdditionalCapability"app" には、Chromium 版 Edge のインストールディレクトリ内にある msedge.exe を指定します。

ページ遷移する

private static void Navigate(WindowsDriver<WindowsElement> driver, string url)
{
    var addressEditBox = driver.FindElement(By.Name("アドレスと検索バー"));
    addressEditBox.Clear();
    addressEditBox.SendKeys(url);

    // 入力候補を消して検索実行
    Thread.Sleep(TimeSpan.FromSeconds(1));
    addressEditBox.SendKeys(Keys.Delete + Keys.Enter);
}

指定したアドレスのページに遷移します。
Selenium のように Url プロパティ設定一発とはいきません。
アドレス欄を取得して SendKeys で入力していくわけですが、アドレス + Enter を渡すだけでは入力候補が適用されてしまうことがあります。
これを防ぐために、[Delete] キーで入力候補を消してから Enter しています。

何かを契機にアドレス欄の入力が全角になってしまうことがありました。
そうなると実行前にIMEモードを半角にしておいても、アドレス入力時に全角に切り替わってしまいます。
発生時、ほかの Edge ウィンドウを開いており、ほかの Edge ウィンドウを閉じて再試行すると解消しました。
たまたまかもしれませんが、ほかの Edge ウィンドウは閉じておくことにします。

該当する要素が出現するまで待機する

単一要素が現れるまで待機
private static WindowsElement WaitUntilElementToBe(
    WindowsDriver<WindowsElement> driver, By elementBy, TimeSpan timeout)
{
    return WaitUntilElementsToBe(driver, elementBy, timeout, false).First();
}
要素群が現れるまで待機
private static ReadOnlyCollection<WindowsElement> WaitUntilElementsToBe(
    WindowsDriver<WindowsElement> driver, By elementsBy, TimeSpan timeout, bool allowEmpty)
{
    ReadOnlyCollection<WindowsElement> elements = null;

    try
    {
        new WebDriverWait(driver, timeout)
            .Until(d =>
            {
                elements = ((WindowsDriver<WindowsElement>)d).FindElements(elementsBy);
                return elements.Any();
            });
    }
    catch (WebDriverTimeoutException)
    {
        if (allowEmpty)
        {
            return elements;
        }

        throw;
    }

    return elements;
}

単なる FindElement / FindElements メソッドでは、存在するはずなのに見つからないことがあります。
SeleniumExtras.WaitHelpers.ExpectedConditionsElementToBeClickable でも見つからないことがありました。
そこでこのようなタイムアウト付きの待機メソッドを実装して呼び出したところ、見つからない問題が発生しなくなりました。
ここは汎用的に使えると思います。

年月を読み上げる

private static void ReadAloudUnselectableText(
    WindowsDriver<WindowsElement> driver, WindowsElement element, TimeSpan readingTime)
{
    new Actions(driver)
        // 要素のコンテキストメニューを開き
        .ContextClick(element)
        // 音声読み上げのショートカットキー "U" を送る
        .SendKeys("U")
        .Perform();

    // 指定時間待って
    Thread.Sleep(readingTime);

    // 読み上げを打ち切る
    element.SendKeys(Keys.Escape);
}

各年の初めに年月を読み上げますが、ヘッダの年月はテキスト選択できません。
テキスト選択できないと「選択部分を音声で読み上げる」機能を実行できません。
コンテキストメニューに出てくるのは「音声で読み上げる」で、クリックするとその位置から読み上げが始まります。

対象要素が終わると次の要素を読み上げていきますので、時間を指定して [Escape] キーで強制的に打ち切ります
年月の場合、その次の要素の「日」(「にち」と読まれる)まで入ってしまうことがありますが、制限事項としてご容赦ください。

全イベントのボタン要素を取得する

private static WindowsElement[] FindEventButtons(WindowsDriver<WindowsElement> driver)
{
    var grid = WaitUntilElementToBe(driver, By.XPath("//*/DataGrid"), TimeSpan.FromSeconds(5));
    var eventButtons = grid.FindElements(By.XPath("//*/Button")).Cast<WindowsElement>().ToArray();

    if (!eventButtons.Any())
    {
        // その年何もイベントがなかった
        return eventButtons;
    }

    // 列ヘッダを取得
    var headerElement = driver.FindElement(By.XPath("//*/DataGrid/DataItem"));

    // Tab キーで先頭イベントに遷移できるよう、直前の要素(該当日の数字)にフォーカスを移す
    var dayElement = driver.FindElement(By.XPath("//*/DataGrid/DataItem/DataItem/Group[starts-with(@Name, \"12月 24日\")]"));
    new Actions(driver)
        // コンテキストメニューを開き
        .ContextClick(dayElement)
        // Escape キーでコンテキストメニュー解除
        .SendKeys(Keys.Escape)
        .Perform();

    return eventButtons;
}

該当日のイベント(予定/リマインダー)ボタン要素をすべて取得します。
祝日カレンダーを入れている場合、その予定も含まれます。

イベントボタン群が見つかったら、先頭イベントボタンの直前のタブ移動要素にフォーカスを移しておきます。
これは少し説明が必要です。

任意の部分だけ読み上げるには、テキストとして選択できなくてはいけません。
カレンダー上のタイトルはテキストとして選択できず、標準のコンテキストメニューも表示されませんので、イベントダイアログを開く必要があります。

登録されたイベント群のダイアログを表示するには、上から順にクリックしていけばいいわけですが、そう簡単にはいきません。
隠れている要素も取得はできるのですが、隠れたままではクリックできない
ならばと MoveToElement しても隠れたまま。
Google カレンダーの「日」ビューページでは、カレンダーの div 要素にスクロールがついています。
そして、WinAppDriver 経由でこれをうまく操れそうにない。
マウスホイールはサポートされていないし、ドキュメント全体のスクロールではないので PageUp / PageDown も効きません。

なんとか方法はないかと手動で画面をいじっていると、[Tab] キーでイベントボタンを順に巡れることがわかりました。
しかしどうやって先頭のボタンに移動するか。
先頭のボタンから [Shift] + [Tab] で一つ戻ると左上の日を表す「24」にフォーカスが当たります。
HTMLを調べると div 要素でした。
ということは Group 要素として取れるはずです。
WinAppDriverUiRecorder で調べると、XPath が "/Group[@Name=\"12月 24日 (火曜日)\"]" のようになっていました。
曜日を取り除いて前方一致にすることでどの年にも対応できます。

イベントのタイトルを読み上げる

private static void ReadAloudEventTitle(WindowsDriver<WindowsElement> driver, WindowsElement eventButton)
{
    // Tab キーで対象のイベントボタンに移動
    new Actions(driver).SendKeys(Keys.Tab).Perform();

    // 【例】18:00~24:00、残業&amp;奇跡、仕事の予定、承諾済み、場所: 会社、2019年 12月 24日
    string eventText = eventButton.Text;
    Console.WriteLine(eventText);

    // イベントダイアログを開く
    eventButton.SendKeys(Keys.Enter);

    // タイトル文字列
    string title = WebUtility.HtmlDecode(eventButton.Text.Split('、')[1]);
    if (title == "タイトルなし")
    {
        // タイトルなしは括弧をつける
        title = $"({title})";
    }
    title = Regex.Replace(title, "^リマインダー: ", "");

    // タイトル要素を見つける
    WindowsElement dialog;
    By titleElementBy;
    WindowsElement titleElement;
    try
    {
        dialog = WaitUntilElementToBe(driver, By.XPath($"//*/Pane[@Name=\"{title}\"]"), TimeSpan.FromSeconds(5));
        titleElementBy = By.XPath($"//*/Group/Text[@Name=\"{title}\"]");
        titleElement = (WindowsElement)dialog.FindElement(titleElementBy);
    }
    catch (WebDriverException ex)
    {
        Console.WriteLine(ex.ToString());
        Assert.Fail($"「{eventText}」のタイトル要素を見つけることができませんでした。");
        return;
    }

    // タイトルの読み上げ
    new Actions(driver)
        // ダイアログ内をクリックしてアクティブにする
        .Click(titleElement)
        // タイトル欄の左上端まで移動
        .MoveToElement(whenElement, 0, -50)
        // ドラッグを開始し
        .ClickAndHold()
        // タイトル欄の右端で
        .MoveByOffset(titleElement.Size.Width + 10, titleElement.Size.Height - 20)
        // ドロップして
        .Release()
        // コンテキストメニューを開き
        .ContextClick()
        // 音声読み上げのショートカットキー "U" を送る
        .SendKeys("U")
        .Perform();

    Thread.Sleep(TimeSpan.FromSeconds(1));

    // 読み上げ終了待機
    try
    {
        // 「選択部分を音声で読み上げる」の終了を待つ
        var readAloudBy = By.XPath("//*/Document[@Name=\"音声で読み上げる\"]");
        new WebDriverWait(driver, TimeSpan.FromSeconds(10))
            .Until(d => !d.FindElements(readAloudBy).Any());
    }
    catch (WebDriverTimeoutException)
    {
        // 「ここから音声で読み上げる」になっていた場合、強制的に打ち切る
        var readAloudCloseButton = driver.FindElement(By.XPath("//*/ToolBar[@Name=\"音声で読み上げる\"]/Group/Group/Button[@Name=\"閉じる\"]"));
        readAloudCloseButton.Click();
    }

    // タイトル要素を再取得
    titleElement = (WindowsElement)dialog.FindElement(titleElementBy);

    // 以降のショートカットを効かせるためにフォーカスを戻す
    titleElement.Click();

    // イベントダイアログを閉じる
    titleElement.SendKeys(Keys.Escape);
}

いよいよ本丸です。
少し長いですが、一つ一つ解説していきます。

[Tab] キーで対象のイベントボタンに移動

[Tab] キーでイベントを巡るのは上で解説したとおりです。
このメソッドはイベントごとに呼ばれますので、まず対象イベントボタンにフォーカスを移動して可視状態にします。

イベントダイアログを開く

フォーカスが移ってアクティブな可視状態になったら、[Enter] キーでイベントダイアログを開きます。
Click メソッドでは開かないことがあり、WebDriverWaitElementToBeClickable 待機しても再発しましたので、クリック方式は見送りました。

タイトル要素を見つける

イベントダイアログを開いたらタイトル要素を見つけます。

イベントボタンの Text プロパティには、"18:00~24:00、残業&amp;奇跡、仕事の予定、承諾済み、場所: 会社、2019年 12月 24日" のような読点区切りの文字列が収められています。
2番目の要素がタイトルです。
例の &amp; のようにHTMLエンコードされた状態で取得されますので、デコードします。
さらにリマインダーは "リマインダー: テストリマインダー" のような書式ですので、キャプション部分を取り除きます。
これでタイトルテキストになります。
これを By.XPath($"//*/Group/Text[@Name=\"{title}\"]") のように渡し、ダイアログ要素からの相対でタイトル要素が取得できます。

タイトルの読み上げ

いよいよタイトルを選択して読み上げます。

テキスト選択

まず、これが一筋縄ではいきませんでした。

タイトルは1文字、1単語から2行の折り返しまであります。
3行以上の場合も2行で打ち切られ、省略記号(三点リーダー)付きで表示されます。

最初に思いついたのはトリプルクリックです。
手動操作で試したところ、折り返される場合もうまく全選択できました。
さらにいいことには、隠れていてマウスドラッグでは選択できなかった3行目以降までが選択され、読み上げの対象となりました。
ただし、その状態でコンテキストメニューを開いて「選択部分を音声で読み上げる」を実行すると通知設定まで読まれてしまいます。
改行まで選択されているためでしょう。
[Ctrl] + [Shift] + [←] キーで改行を取り除くと、タイトルのみが読み上げられるようになりました。

このままめでたしとは行きません。
トリプルクリック…できません
IsActionExecutortrue の Driver であれば PointerInputDevice.CreatePointerDown, CreatePointerDown の組み合わせで実現できそうですが、WindowsDriver<WindowsElement> + WinAppDriver ではサポートされておらず、ClickDoubleClick の組合せでも実現できませんでした。
残念ですが、諦めましょう。

素直にタイトルテキストをドラッグ&ドロップで選択することにします。
起点はタイトル要素の左上端です。
Actions.MoveToElement は要素のみの指定だとその要素の中央に移動しますが、オフセットを指定する場合は左上端が起点となります。
ドラッグ&ドロップは ClickAndHoldMoveByOffsetRelease です。
Release した後には MoveByOffset で選択領域内に移動しておくとより丁寧ですが、なくても動作しますので省きました。

タイトルが3行あると日時まで選択されてしまいますが、制限事項とさせていただきます。

読み上げと終了待機

テキストを選択したら、コンテキストメニューから読み上げを実行します。
読み上げ指示は年月と同じですが、今度は選択部分のみの読み上げですので、終了待機の方法が変わります。
選択部分のみ読み上げる場合、終わると読み上げツールバーは自動的に閉じられますので、これを検出することで読み終えたと判断できそうです。

初めに SeleniumExtras.WaitHelpers.ExpectedConditions.InvisibilityOfElementLocated を試してみましたが、要素自体が消えるとエラーになってしまうようで、ここには使えませんでした。
それでは WebDriverWaitFindElements が要素を返さなくなるまで待機することにしましょう。

ここもすんなりとは通してくれません。
「旅行」の読み上げが止まらず、日時、時刻と突き進んでしまいます。
よく見ると、コンテキストメニューが「選択部分を音声で読み上げる」でなく「ここから音声で読み上げる」になっています。
確かな基準は不明ですが、2文字以内の単語や「マススパー」「クリスマスイブ」のような日本語として一つになった単語、「12345678」のような数字のみで発生します。
これは手動操作でも同じでした。
であれば仕方ありません。
WebDriverWait に設定したタイムアウトを経過すると WebDriverTimeoutException が発生しますので、捕捉して強制的に打ち切ることにしましょう。
そのうちきちんと「選択部分を音声で読み上げる」になるよう、 Edge で修正されるかもしれませんが、それまでは制限事項です。

イベントダイアログを閉じる

読み上げ後、そのままだとダイアログに対してショートカットが効きません。
タイトル要素をクリックしてフォーカスを戻してから、[Escape] キーでイベントダイアログを閉じます。
タイトル要素は上ですでに取得していますが、読み上げ後には再取得が必要です。

制限事項

  • 思いつく限りのイベントの登録パターンに対応しましたが、対応できていないパターンもあるかもしれません。
  • 特定の環境でしかテストはしていません。うまくいかないときは引数、プロパティの数値など調整してみてください。
  • その他本文内に記載しています。

感想

ベータ版ではすんなり行かないことが多く、試行錯誤の連続でしたが、正式リリース版ではそうした問題がかなり解消されていました。

今回のような特別な要件がない限り、Web アプリケーションは Selenium で扱った方がいいですが、

  • WinAppDriver でも頑張ればある程度はできる
  • それでもできないこともある

を示すことができていれば、ひとまず挑戦してよかったと思います。

音声に関しては、新しく追加された「Haruka Online」は「Haruka」よりは確かに機械っぽさが薄れた気はするものの、英語のたとえば「Jessa Online」の流暢さと比べるとぎこちなさは否めません。
まだまだ改良の余地がありそうです。
選択した部分だけ読み上げられるようになったのはいいですね。
ちなみにこの記事も公開前、おかしなところがないかチェックするため、Haruka Online さんに読み上げてもらいました3
もう少し自然になればプレゼンとかでも使えそうです。

やりがいはありました。
まだいろいろ遊べそうではあります。(またいつか、苦労を忘れた頃に)


Qiita に書くようになって初めての Advent Calendar に参加してみました。
クリスマスにちなんだ記事にしましたが、もっと C# っぽいことを書くべきだったかもしれません…ご寛容を。


  1. これまでの EdgeHTML 版 Edge は UWP アプリケーションでしたが、Chromium 版 Edge は Win32 アプリケーションとして開発されています。 

  2. Chrome 同様、情報バーに「自動テスト ソフトウェアによって制御されています」と表示されますので、Chromium の仕様と考えられます。 

  3. コード部分を日本語で読み上げると凄いことになってちょっと面白いですね。英語に切り替えるとそれっぽく聞こえますが、飛ばしましょう。次の章の先頭文字を選択すると、そこから続行してくれます。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away