ブラウザ操作は Selenium？ いえ WinAppDriver でも ―自動UIテストで遊ぼう：Qiita に「いいね」がついてたら

<font color="red">※2020年1月15日に正式リリースされた Chromium 版 Edge には対応していません。Chromium 版 Edge を操作するコードについては次の記事をご参照ください。</font>
[[C#] 自動UIテストで遊ぼう：生まれ変わったエッヂのはるかさんがクリスマスイブの予定を読み上げる（WinAppDriver でできること／できないこと）](https://qiita.com/CodeOne/items/6a7e75fc82f94b332f14)

# はじめに

マイクロソフト社の開発した [Windows Application Driver (WinAppDriver)](https://github.com/microsoft/WinAppDriver) を使用すると、Windows 10 上で動くアプリケーションのUIテストを自動化することができます。

Webブラウザの自動操作と言えば、一般的には Selenium WebDriver を使いますが、ふと興味が湧いて Appium + WinAppDriver でも挑戦してみました。

身近なところで、Qiita の「いいね」を検証しましょう。
新たに「いいね」がついていたら成功、ついていなければ失敗とします。

# この記事で得られるかもしれない知見

- <font color="#E14B22">**Selenium WebDriver と WinAppDriver のテストコード差異**</font>
- Selenium WebDriver／WinAppDriver で**<font color="#E14B22">色を検証</font>**する方法
- WinAppDriver の限界
- 珍しめ以上アクロバティック以下の小技
    - `SendKeys` で<font color="#E14B22">**USキーボードしかサポートされない**</font>（'=' は '^' になる）問題の対策
    - `SendKeys` で <font color="#E14B22">**入力候補まで送られてしまう**</font>問題の対策（ここではアドレス欄）
- <font color="#E14B22">**Microsoft Edge（EdgeHTML／Chromium）のUIオートメーション対応**</font>に関するプチ情報

# シナリオ
1. Qiita にログインします。
2. 右上の通知ボックスで新着通知の有無を確認します。
3. 新着通知があったら通知一覧ページを開きます[^1]。
4. 新しい順に10件ずつ表示されるので、新着件数分の内訳を確認します。

# 期待結果

新たに「いいね」がついていること。

# 想定パターン

1. 新着通知がない。
2. 新着通知はあるが、その中に「いいね」はない。
3. 新着通知の中に「いいね」がある。

# 検証環境
## テスト実行環境

- Windows 10
- Microsoft Edge（EdgeHTML）
- Visual Studio
- NuGet パッケージ
    - [Selenium WebDriver API .NET Bindings](https://www.nuget.org/packages/Selenium.WebDriver) v4.0.0-alpha04
    - [Selenium.Support (Selenium WebDriver .NET Bindings support classes)](https://www.nuget.org/packages/Selenium.Support/) v4.0.0-alpha04
    - [Appium.WebDriver (appium-dotnet-driver)] (https://www.nuget.org/packages/Appium.WebDriver/) v4.1.1
    - [DotNetSeleniumExtras.WaitHelpers](https://www.nuget.org/packages/dotnetseleniumextras.waithelpers/) v3.11.0

## UI実行環境（テスト実行環境と同居可）
- Microsoft Edge（EdgeHTML）
- [Microsoft WebDriver (Chromium Edge 向け)](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/)  
- [WinAppDriver (Windows Application Driver)](https://github.com/microsoft/WinAppDriver) v1.1  
※Windows の「開発者モード」を有効にしたうえで、管理者権限で起動しておきます。

# テストコード

コードは C# で記述しますが、ほかの言語でも同じロジックで実現できると思います。

## Selenium WebDriver 版

一般的な Selenium WebDriver の方から実装していきます。

Edge で Qiita に自動ログインできるようにしておきます。
実行前に Edge を開いていると `Url` 設定時に `NoSuchWindowException` が発生してしまいますので閉じてから実行しましょう。

### テキストで検証

まず `EdgeDriver` を使用して Qiita のトップページを開きます。
次に通知ボックスの `div` 要素を見つけ、そのテキストから新着件数を取得します。
ここで件数が 0 なら「いいね」はありませんので検証は失敗となります。

新着通知があった場合、その中に「いいね」が含まれていることを検証します。
10件までは通知件数クリックからドロップダウンで確認できますが、それを超えることもありえます。
通知一覧ページを開いて確認することにしましょう。

通知一覧ページは10件ごとですので、新着件数がそれを超えるときは page クエリパラメータを指定して次ページ以降を確認していきます。

「いいね」の場合は `<span class="bold">いいね</span>` で強調表示されます。
それがついた通知が1つでもあれば検証成功です。

コードを見てみましょう。

```csharp
[TestMethod]
public void SomebodyShouldGiveMeLike_WebDriver_ByText()
{
    const string QiitaRootUrl = "https://qiita.com/";
    const string QiitaNotificationsUrl = "https://qiita.com/notifications";

    var edgeDriver = new EdgeDriver();

    // Qiita にアクセス
    try
    {
        edgeDriver.Url = QiitaRootUrl;
    }
    catch (NoSuchWindowException ex)
    {
        Console.WriteLine(ex.Message);
        Assert.Fail("Edge を閉じてから再試行してください。");
    }

    new WebDriverWait(edgeDriver, TimeSpan.FromSeconds(20))
        .Until(SeleniumExtras.WaitHelpers.ExpectedConditions.UrlToBe(QiitaRootUrl));

    // 新着はあるか
    var notificationDiv = edgeDriver.FindElement(By.ClassName("st-Header_notifications"));
    int notificationCount = int.Parse(notificationDiv.Text);
    if (notificationCount == 0)
    {
        Assert.Fail("新着通知はありません。");
        return;
    }

    Console.WriteLine($"新着通知が{notificationCount}件ありました。");

    // 新着の中に「いいね」はあるか
    const int PageItemCount = 10;
    int newLikeCount = 0;

    for (int pageIndex = 0; pageIndex <= notificationCount / PageItemCount; pageIndex++)
    {
        string url = QiitaNotificationsUrl + $"?page={pageIndex + 1}";
        edgeDriver.Url = url;

        new WebDriverWait(edgeDriver, TimeSpan.FromSeconds(20))
            .Until(SeleniumExtras.WaitHelpers.ExpectedConditions.UrlToBe(url));

        newLikeCount += edgeDriver.FindElements(By.CssSelector("li.notification"))
            .Take(notificationCount - (pageIndex * PageItemCount))
            .Count(notification =>
            {
                // <span class="bold">いいね</span> を探す。
                return notification.FindElements(By.CssSelector("span.bold"))
                    .Any(e => e.Text == "いいね");
            });
    }

    if (newLikeCount == 0)
    {
        Assert.Fail("「いいね」はありませんでした。");
    }

    Console.WriteLine($"{newLikeCount}件の「いいね」がつきました。");
}
```

起動したブラウザを検証後に閉じたいときは、`EdgeDriver` を `Close` または `Dispose` します。

### 色で検証

新着があるかどうは色でも判定できます。

```csharp
[TestMethod]
public void SomebodyShouldGiveMeLike_WebDriver_ByColor()
{
    const string QiitaUrl = "https://qiita.com/";
    var arrivalBackgroundArgb = ColorTranslator.FromHtml("#E14B22").ToArgb();

    var edgeDriver = new EdgeDriver();

    // Qiita にアクセス
    try
    {
        edgeDriver.Url = QiitaUrl;
    }
    catch (NoSuchWindowException ex)
    {
        Console.WriteLine(ex.Message);
        Assert.Fail("Edge を閉じてから再試行してください。");
    }

    new WebDriverWait(edgeDriver, TimeSpan.FromSeconds(20))
        .Until(SeleniumExtras.WaitHelpers.ExpectedConditions.UrlToBe(QiitaUrl));

    // 新着はあるか
    var notificationDiv = edgeDriver.FindElement(By.ClassName("st-Header_notifications"));
    string backgroundColorCssValue = notificationDiv.GetCssValue("background-color");
    var match = Regex.Match(backgroundColorCssValue, @"rgb\((\d+),\s*(\d+),\s*(\d+)\)");
    Assert.IsTrue(match.Success, $"想定と違う色書式：{backgroundColorCssValue}");

    var backgroundColor = Color.FromArgb(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), int.Parse(match.Groups[3].Value));
    if (backgroundColor.ToArgb() != arrivalBackgroundArgb)
    {
        Assert.Fail("新着通知はありません。");
    }

    // 新着の中に「いいね」はあるかは色では判定できません。
    // 上のテキストによる検証と同じになるので省略します。
}
```

`div` 要素のスタイル `background-color` を `RemoteWebElement.GetCssValue` メソッドで参照すると、"rgb(88,29,13)" のような書式で色指定が返ってきます。
ここから正規表現を使ってRGB値をそれぞれ抜き出し、`Color` 構造体オブジェクトに変換しています。

同一色かどうかは `ToArgb()` した結果の `int` 値で判定します。
理由については別記事『[[.NET] コードを見直したくなる「値型」等価判定の思わぬ落とし穴（特殊編）](https://qiita.com/CodeOne/items/4e22a1aa1b4d8c90e435)』をご参照ください。

--- 

ここまでは比較的普通ですね。
ここからが冒険です。

## WinAppDriver 版

一般的には WinAppDriver はデスクトップアプリケーションを扱うためのものですが、Microsoft Edge（ここでは EdgeHTML 版を使用）はHTMLドキュメント部分も MSAA (Microsoft Active Accessibility) の後継である「[UIオートメーション](https://blogs.msdn.microsoft.com/japan_platform_sdkwindows_sdk_support_team_blog/2011/05/26/ui-automation/)」に対応しており、WinAppDriver で扱うことができます。

※Windows 版 Chrome の場合、[「UIオートメーション」対応は「非常に限定的」](http://dev.chromium.org/developers/design-documents/accessibility)とされていますが、MSAA の一部である IAccessible や、MSAA を補完した [IAccessible2](https://wiki.linuxfoundation.org/accessibility/iaccessible2/overview) に対応しており[^2]、v78.0.3904.97 で試したところ、Edge と同レベルのことは実現できそうでした。

### テキストで検証

通知ボックスをダイレクトに取得する情報が `WindowsElement` から得られず、この点に苦労しました。
左隣りの [投稿する] リンクが `Text` プロパティに収められた `href` 属性値から特定できたので、そこから相対位置で取得することにします。
XPath の `following-sibling` は効きませんので、親要素から `FindElementsByXPath("*/*")` で全子要素コレクションを取得し、[投稿する] の次の要素を通知ボックスとして取得しています。

Selenium WebDriver と違い、WinAppDriver ではHTML要素は取得できません。
通知が「いいね」であることは、

> 」 に いいね しました。

という文字列を含んでいるかどうかで判定しました。

次の小技もコード内に含まれています。

- 入力候補が適用されないよう、消してから検索実行する。
- USキーボードレイアウトしかサポートされておらず、'=' は '^' に変わってしまうのでASCIIコードで入力（[『ASCII 文字の挿入』参考](https://support.office.com/ja-jp/article/ascii-%E3%81%BE%E3%81%9F%E3%81%AF-unicode-%E3%83%A9%E3%83%86%E3%83%B3%E8%AA%9E%E3%83%99%E3%83%BC%E3%82%B9%E3%81%AE%E8%A8%98%E5%8F%B7%E3%81%A8%E6%96%87%E5%AD%97%E3%82%92%E6%8C%BF%E5%85%A5%E3%81%99%E3%82%8B-d13f58d3-7bcb-44a7-a4d5-972ee12e50e0#bm1)）。

```csharp
[TestMethod]
public void SomebodyShouldGiveMeLike_WinAppDriver_ByText()
{
    const string EdgeAppId = "Microsoft.MicrosoftEdge_8wekyb3d8bbwe!MicrosoftEdge";
    const string QiitaUrl = "https://qiita.com/";
    const string QiitaNotificationsUrl = "https://qiita.com/notifications";

    var remoteAddress = new Uri("http://127.0.0.1:4723");
    var edgeCapabilities = new AppiumOptions();
    edgeCapabilities.AddAdditionalCapability("app", EdgeAppId);
    var edgeWinDriver = new WindowsDriver<WindowsElement>(remoteAddress, edgeCapabilities);

    // Qiita にアクセス
    var addressEditBox = edgeWinDriver.FindElementByAccessibilityId("addressEditBox");
    addressEditBox.Clear();
    addressEditBox.SendKeys(QiitaUrl);
    // 入力候補を消して検索実行
    Thread.Sleep(TimeSpan.FromSeconds(2));
    addressEditBox.SendKeys(Keys.Delete + Keys.Enter);

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

    // 新着はあるか
    var qiitaPane = edgeWinDriver.FindElementByXPath(
        $".//Pane[@ClassName=\"Internet Explorer_Server\"][@Name=\"{QiitaUrl}\"]/Pane[@Name=\"Qiita\"]");

    // [投稿する] の次の要素が通知ボックス
    // ※ダイレクトに特定するための情報が得られない。
    // 　Inspect.exe で見ると Name が "投稿する" だが、テスト実行時の Text 値は href 属性値だった。
    // 　following-sibling も効かないので全子要素コレクションから Skip 取得。
    var notificationGroup = qiitaPane.FindElementsByXPath("*/*").Cast<WindowsElement>()
        .SkipWhile(e => e.Text != "https://qiita.com/drafts/new").Skip(1)
        .First();
    int notificationCount = int.Parse(notificationGroup.FindElementByTagName("Text").Text);
    if (notificationCount == 0)
    {
        Assert.Fail("新着通知はありません。");
    }
    
    Console.WriteLine($"新着通知が{notificationCount}件ありました。");

    // 新着の中に「いいね」はあるか
    const int PageItemCount = 10;
    int newLikeCount = 0;

    // ※USキーボードレイアウトしかサポートされておらず、'=' は '^' に変わってしまうのでASCIIコードで入力。
    // 　Keys.Shift + "-" + Keys.Shift でも '=' になる。
    string usKeyboardEqual = Keys.Alt + Keys.NumberPad6 + Keys.NumberPad1 + Keys.Alt;

    for (int pageIndex = 0; pageIndex <= notificationCount / PageItemCount; pageIndex++)
    {
        string pageUrl = $"{QiitaNotificationsUrl}?page{usKeyboardEqual}{pageIndex + 1}";

        addressEditBox.Clear();
        addressEditBox.SendKeys(pageUrl);
        Thread.Sleep(TimeSpan.FromSeconds(2));
        addressEditBox.SendKeys(Keys.Delete + Keys.Enter);

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

        var notifications = edgeWinDriver.FindElementsByXPath("//Pane[@Name=\"通知一覧 - Qiita\"]/List/ListItem");
        newLikeCount += notifications
            .Take(notificationCount - (pageIndex * PageItemCount))
            .Count(n => n.Text.Contains("」 に いいね しました。"));
    }

    if (newLikeCount == 0)
    {
        Assert.Fail("「いいね」はありませんでした。");
    }

    Console.WriteLine($"{newLikeCount}件の「いいね」がつきました。");
}
```

### 色で検証

Selenium WebDriver と違い、HTMLのスタイルは参照できません。
`RemoteWebDriver` にスクリーンショットを取得するメソッドが用意されていますので、レンダリングされた色から判定してみましょう。

![notify.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/335669/7dc332c1-91c9-52b0-01b8-2b16b26acee8.png)

これが `RemoteWebDriver.GetScreenshot()` メソッドでキャプチャした通知画像です。
2桁とかあると格好よかったのですが、これでも1日待ちました。
真っ赤に見えたのは背景色との対比のせいで、実際は意外とオレンジなんですね。

![いいね色.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/335669/61f2491a-9cdc-10fc-c813-747184d2a1d8.png)

ブラウザのデバッグツールで色指定を確認すると "#E14B22" でした。

ウィンドウのスクリーンショットから通知ボックス部分を抜き出し、そこに "#E14B22" のピクセルが含まれているかどうかを検証します。

```csharp
[TestMethod]
public void SomebodyShouldGiveMeLike_WinAppDriver_ByColor()
{
    const string EdgeAppId = "Microsoft.MicrosoftEdge_8wekyb3d8bbwe!MicrosoftEdge";
    const string QiitaUrl = "https://qiita.com/";
    const string QiitaNewPostHref = "https://qiita.com/drafts/new";
    var arrivalBackgroundArgb = ColorTranslator.FromHtml("#E14B22").ToArgb();

    var remoteAddress = new Uri("http://127.0.0.1:4723");
    var edgeCapabilities = new AppiumOptions();
    edgeCapabilities.AddAdditionalCapability("app", EdgeAppId);
    var edgeWinDriver = new WindowsDriver<WindowsElement>(remoteAddress, edgeCapabilities);

    // Qiita にアクセス
    var addressEditBox = edgeWinDriver.FindElementByAccessibilityId("addressEditBox");
    addressEditBox.Clear();
    addressEditBox.SendKeys(QiitaUrl);
    // 入力候補を消して検索実行
    Thread.Sleep(TimeSpan.FromSeconds(2));
    addressEditBox.SendKeys(Keys.Delete + Keys.Enter);

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

    // 新着はあるか
    var qiitaPane = edgeWinDriver.FindElementByXPath(
        $"//Pane[@ClassName=\"Internet Explorer_Server\"][@Name=\"{QiitaUrl}\"]/Pane[@Name=\"Qiita\"]");
    
    // [投稿する] の次の要素が通知ボックス
    var notificationGroup = qiitaPane.FindElementsByXPath("*/*").Cast<WindowsElement>()
        .SkipWhile(e => e.Text != "https://qiita.com/drafts/new").Skip(1)
        .First();
    var notificationRect = new Rectangle(notificationGroup.Location, notificationGroup.Size);

    var edgeScreen = edgeWinDriver.GetScreenshot();
    using (var edgeScreenStream = new MemoryStream(edgeScreen.AsByteArray))
    using (var edgeScreenBitmap = new Bitmap(edgeScreenStream))
    using (var notificationBitmap = edgeScreenBitmap.Clone(notificationRect, edgeScreenBitmap.PixelFormat))
    {
        // 左上から右下に向けて赤を探す（背景が赤なら見つかるはず）
        bool hasNew = Enumerable.Range(0, Math.Min(notificationBitmap.Width, notificationBitmap.Height))
            .Any(xy => notificationBitmap.GetPixel(xy, xy).ToArgb() == arrivalBackgroundArgb);

        if (!hasNew)
        {
            Assert.Fail("新着通知はありません。");
        }
    }

    // 新着の中に「いいね」はあるかは色では判定できません。
    // 上のテキストによる検証と同じになるので省略します。
}
```

できましたね。
「いいね」はあったでしょうか。

# Microsoft Edge のUIオートメーション対応

Edge（EdgeHTML）はHTMLドキュメント部分も限定的ながら「UIオートメーション」に対応しており、上のように WinAppDriver で扱うことができましたが、Windows フォームなどデスクトップアプリケーションと比べると、Inspect.exe に出てくる Name に Appium + WinAppDriver でアクセスできない、AccessibilityId が提供されないなど、機能的に物足りない感は否めず、実装に苦労しました。
あまり実用的とは言えませんね。
HTMLドキュメントの操作には素直に Selenium WebDriver を使用した方がよいでしょう。

なお、レンダリングエンジンが Chromium に変わった後の Edge でUI自動化がどのようにサポートされるのか、気になるところですが、ベータ版を Inspect.exe で確認したところ、Chrome 同様に IAccessible2 を通してサポートされているようでした（レンダリングエンジンが同じなのでそれはそうですね）。

※2019年11月4日、Chromium エンジンを採用した Microsoft Edge の安定版が 2020年1月15日にリリースされる予定と発表されました。
[Getting your sites ready for the new Microsoft Edge - Microsoft Edge Blog](https://blogs.windows.com/msedgedev/2019/11/04/edge-chromium-release-candidate-get-ready/#22j2x2j3Et7s2osK.97)

[^1]: 通知一覧を開いた瞬間に新着の赤い背景は消えてしまいます。自分の手で赤い通知ボックスをクリックしたい方、新着ありの状態を長めに味わいたい方はご注意ください。

[^2]: `chrome://accessibility/` で開いているページのツリー構造を確認できます。