C#でSeleniumおよびAppiumクライアントを開発したときの覚書

## はじめに

C#でSeleniumおよびAppiumクライアントを開発したときの覚書です。

- クライアント開発・動作環境
OS : Windows10 Pro
IDE : Visual Studio 2017

- 使ったバージョン
Selenium : 3.14
Appium : 4.0.0.4-beta

## 参考ドキュメント

[Selenium API Reference](!https://seleniumhq.github.io/selenium/docs/api/dotnet/index.html)
[チートシート](https://www.automatetheplanet.com/selenium-webdriver-csharp-cheat-sheet/)
[逆引きクイックリファレンス](http://www.seleniumqref.com/api/webdriver_gyaku.html)（※C#はないが何となく雰囲気参考になる）
[appium dot-net-driver wiki](https://github.com/appium/appium-dotnet-driver/wiki)

## 最初理解が難しかったポイント
- WebDriver 
各種ブラウザを外部からAPIで制御するためのドライバ。ブラウザごと（エンジンごと）に異なるWebDriverが存在する。SeleniumはWebDriverに対する入力を仲介してくれる。

- SeleniumからWebDriverへのアクセス方法 
アクセス方法は2種類あり、一つはクライアントからWebDriverに直接アクセスする方法。もう一つはSelenium Serverを介してWebDriverにアクセスする方法。
前者の場合、ブラウザに対応するWebDriverのインスタンスを使う。後者の場合、RemoteWebDriverインスタンスを使う。
RemoteWebDriverの場合、インスタンス作成時に引き渡すオプションでブラウザの種類を指定する。

- DesiredCapabilities 
Selenium3ではDriverOptions(を継承した)インスタンスに変更されている。昔のバージョンの記事を見るとDesiredCapabilitiesの指定がいっぱいあってちょっと混乱する。
インスタンス作成時のオプション指定みたいなもの。DriverOptionのプロパティに含まれていないCapability指定も存在する。
しかしどのブラウザでどういったCapability指定が使用できるかの調べ方がいまだによくわからない。どこかに一覧ないかなぁ。。

- WindowsのC#でクライアントを開発したときにMacのSafariやiPhoneのテストができるのか
実機があればできる。Mac上にSelenium Standalone Server、Appium Serverを立てて、それを仲介してRemoteWebDriverで操作する。スクリーンショットもWindows側にリモートで取得できる。便利！

## 導入方法

### Selenium
nugetパッケージ

```powershell:パッケージマネージャーコンソール
Install-Package Selenium.WebDriver
Install-Package Selenium.RC
Install-Package Selenium.WebDriverBackedSelenium
Install-Package Selenium.Support
Install-Package DotNetSeleniumExtras.WaitHelpers
Install-Package DotNetSeleniumExtras.PageObjects
Install-Package Selenium.WebDriver.ChromeDriver
Install-Package Selenium.WebDriver.IEDriver
Install-Package Selenium.WebDriver.GeckoDriver
Install-Package Selenium.WebDriver.MicrosoftWebDriver
```

### Appium
nugetパッケージ

```powershell:パッケージマネージャーコンソール
Install-Package Appium.WebDriver -Version 4.0.0.4-beta
```
AppiumはSeleniumのラッパーであり、Selenium3.13以降にはAppium3.xでは対応しておらずAppium4.x以上が必要。現時点ではbeta版しかない。(2019/2/3時点)

## その他細かい点
- IEはハードコピーがうまく取得できない。（変に切れたり偏ったりする。謎。）
　→Windowsのディスプレイ表示比率を100%にするとうまく取れるようになります。（2019/2/17追記）

- IEはWindowsのディスプレイ表示比率が100%でないとクリックが効かない。(2019/2/17追記）

- IEは拡大率100%にしておかないと起動時エラーになる。（オプションで無視して起動もできるが、ただでさえうまく取得できないハードコピーがさらにいまいちになる。）

- IEはセキュリティ設定の保護モードのON/OFFがすべての設定（イントラ、インターネット、信頼済みサイト等）でどちらかに統一されている必要がある。
混在していると何故かウインドウやエレメントが取得できない。

- Javascriptで新規タブを作成する場合、ポップアップブロックがOFFになっている必要がある。

- Firefoxはインストール後に一度起動してある程度いじってからでないとエラーになる。
（具体的にどこをどうしたら大丈夫なのかは不明）

## コードサンプル
メンバ定義や自分で作ったメソッドの定義を載せていないものもあるので考え方の参考程度に。

#### Driverインスタンスの作成
参考にさせて頂いた記事 : [C#でSelenium2を使用して主要ブラウザを動かしてみた](https://qiita.com/whoshino/items/603d92f0560276558188)

```C#:WebDriverFactory.cs
using OpenQA.Selenium;
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Android;
using OpenQA.Selenium.Appium.Enums;
using OpenQA.Selenium.Appium.iOS;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Edge;
using OpenQA.Selenium.Firefox;
using OpenQA.Selenium.IE;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Safari;

    static class WebDriverFactory
    {
        /// <summary>
        /// 対象のWebDriverのインスタンスを作成します。
        /// </summary>
        public static IWebDriver CreateInstance(AppSettings.BrowserName browserName)
        {
            try
            {
                switch (browserName)
                {
                    case AppSettings.BrowserName.None:
                        throw new ArgumentException(string.Format("Not Definition. BrowserName:{0}", browserName));

                    case AppSettings.BrowserName.Chrome:
                        ChromeOptions ChromeOption = new ChromeOptions();
                        ChromeDriverService ChromeDService = ChromeDriverService.CreateDefaultService();
                        return new ChromeDriver(ChromeDService, ChromeOption);

                    case AppSettings.BrowserName.Firefox:
                        FirefoxOptions FirefoxOption = new FirefoxOptions();
                        FirefoxDriverService FFDService = FirefoxDriverService.CreateDefaultService();
                        //Selenium3.12以前
                        //return new FirefoxDriver(FFDService, FirefoxOption,new TimeSpan(0,0,60));
                        //Selenium3.13以降
                        return new FirefoxDriver(FFDService, FirefoxOption);

                    case AppSettings.BrowserName.InternetExplorer:
                        InternetExplorerOptions IEOption = new InternetExplorerOptions();
                        //ズームは100%でないとスクリーンショットが切れる（これでも切れることがある）
                        IEOption.IgnoreZoomLevel = false;
                        //保護モードが混在していると要素が取得できない
                        IEOption.IntroduceInstabilityByIgnoringProtectedModeSettings = false ;
                        InternetExplorerDriverService IEService = InternetExplorerDriverService.CreateDefaultService("./", "IEDriverServer.exe");
                        return new InternetExplorerDriver(IEService, IEOption);

                    case AppSettings.BrowserName.Edge:
                        EdgeOptions EdgeOption = new EdgeOptions();
                        EdgeDriverService EdgeDService = EdgeDriverService.CreateDefaultService();
                        return new EdgeDriver(EdgeDService, EdgeOption);

                    default:
                        throw new ArgumentException(string.Format("Not Definition. BrowserName:{0}", browserName));
                }
            }
            catch (Exception)
            {
                //エラーが発生したらWebDriverを強制終了する
                ProcessHandler.KillWebDriverProcess();

                //エラーはそのまま上位に返す
                throw;
            }
        }


        /// <summary>
        /// 対象のリモートWebDriverインスタンスを作成します。
        /// </summary>
        /// <returns></returns>
        public static IWebDriver CreateRemoteInstance(AppSettings.RemoteBrowserName browserName, string ipAddress, string OsVersion, string DeviceName)
        {
            try
            {
                //Appium3以前
                //DesiredCapabilities capabilities = new DesiredCapabilities();

                //Appium4以降
                AppiumOptions capabilities = new AppiumOptions();


                switch (browserName)
                {
                    case AppSettings.RemoteBrowserName.None:
                        throw new ArgumentException(string.Format("Not Definition. BrowserName:{0}", browserName));

                    case AppSettings.RemoteBrowserName.Chrome:
                        ChromeOptions ChromeOption = new ChromeOptions();
                        return new RemoteWebDriver(new Uri("http://" + ipAddress + ":4444/wd/hub"), ChromeOption);

                    case AppSettings.RemoteBrowserName.Safari:
                        SafariOptions SafariOption = new SafariOptions();
                        return new RemoteWebDriver(new Uri("http://" + ipAddress + ":4444/wd/hub"), SafariOption);
                    case AppSettings.RemoteBrowserName.iOS:
                        //Appium3以前
                        //capabilities.SetCapability(MobileCapabilityType.PlatformName, "iOS");
                        //capabilities.SetCapability(MobileCapabilityType.PlatformVersion, OsVersion);
                        //capabilities.SetCapability(MobileCapabilityType.DeviceName, DeviceName);
                        //capabilities.SetCapability(MobileCapabilityType.BrowserName, "Safari");
                        //capabilities.SetCapability(MobileCapabilityType.Udid, "auto");
                        //capabilities.SetCapability(MobileCapabilityType.AutomationName, "XCUITest");

                        //Appium4以降
                        capabilities.PlatformName = "iOS";
                        capabilities.AddAdditionalCapability(MobileCapabilityType.PlatformVersion, OsVersion);
                        capabilities.AddAdditionalCapability(MobileCapabilityType.DeviceName, DeviceName);
                        capabilities.AddAdditionalCapability(MobileCapabilityType.BrowserName, "Safari");
                        capabilities.AddAdditionalCapability(MobileCapabilityType.Udid, "auto");
                        capabilities.AddAdditionalCapability(MobileCapabilityType.AutomationName, "XCUITest");


                        return new IOSDriver<IOSElement>(new Uri("http://" + ipAddress + ":4723/wd/hub"), capabilities);

                    case AppSettings.RemoteBrowserName.Android:
                        //Appium3以前
                        //capabilities.SetCapability(MobileCapabilityType.PlatformName ,"Android");
                        //capabilities.SetCapability(MobileCapabilityType.PlatformVersion, OsVersion);
                        //capabilities.SetCapability(MobileCapabilityType.DeviceName, DeviceName);
                        //capabilities.SetCapability(MobileCapabilityType.BrowserName, "Chrome");

                        //Appium4以降
                        capabilities.PlatformName = "Android";
                        capabilities.AddAdditionalCapability(MobileCapabilityType.PlatformVersion, OsVersion);
                        capabilities.AddAdditionalCapability(MobileCapabilityType.DeviceName, DeviceName);
                        capabilities.AddAdditionalCapability(MobileCapabilityType.BrowserName, "Chrome");


                        return new AndroidDriver<AppiumWebElement>(new Uri("http://" + ipAddress + ":4723/wd/hub"), capabilities);
                    default:
                        throw new ArgumentException(string.Format("Not Definition. BrowserName:{0}", browserName));
                }
            }
            catch (Exception)
            {
                //エラーが発生したらWebDriverを強制終了する
                ProcessHandler.KillWebDriverProcess();

                //エラーはそのまま上位に返す
                throw;
            }
        }
    }
}

```

#### セレクタの種類と名前を指定して要素の探索
```C#:
        private delegate By ByLocaterDelegate(string nameToFind);

        private static Dictionary<string, ByLocaterDelegate> byLocaterList
            = new Dictionary<string, ByLocaterDelegate>()
            {
                {"classname",By.ClassName},
                {"cssselector",By.CssSelector},
                {"id",By.Id},
                {"linktext",By.LinkText},
                {"name",By.Name},
                {"partiallinktext",By.PartialLinkText},
                {"tagname",By.TagName},
                {"xpath",By.XPath},
            };

        /// <summary>
        /// 要素の種類と探索する要素名を指定してbyセレクタを返す
        /// </summary>
        /// <param name="elementType">要素の種類</param>
        /// <param name="nameToFind">探索する名称</param>
        /// <returns>byセレクタ</returns>
        private static By ByElementName(string elementType, string nameToFind)
        {
            if (byLocaterList.ContainsKey(elementType.ToLower()))
            {
                ByLocaterDelegate byLocater = byLocaterList[elementType.ToLower()];
                return byLocater(nameToFind);
            }
            else
            {
                //指定がない場合はIDとする
                ByLocaterDelegate byLocater = byLocaterList["id"];
                return byLocater(nameToFind);
            }
        }

```

```C#:呼び出し
        element = Driver.FindElement(ByElementName(elementType, nameToFind));
```

#### 複数フレームの要素探索

要素が見つからなかったときに例外で返すのが個人的には好きじゃないです。
じゃあどう返せばいいのかと言われると難しいですが。nullよりは例外のほうがいいんですかね～。うーん。

※waitはWebDriverWaitインスタンス。（以降全部同じ）

```C#:
        /// <summary>
        /// 複数フレーム内の要素探索を行う
        /// </summary>
        /// <param name="elementType">要素の種類</param>
        /// <param name="nameToFind">要素の名前</param>
        /// <exception cref="NoSuchElementException"></exception>
        private IWebElement FindElementInFrame(string elementType, string nameToFind)
        {
            //フレームがある場合、要素が見つかるまでフレーム間を移動する。
            List<IWebElement> frameElements = Driver.FindElements(By.TagName("frame")).ToList<IWebElement>();
            if (frameElements.Count > 0)
            {

                for (int i = 0; i < frameElements.Count; i++)
                {
                    try
                    {
                        //一旦親ウインドウに戻ってから次のフレームに移動しないとエラーになる
                        wait.Until(drv => drv.SwitchTo().DefaultContent());
                        wait.Until(drv => drv.SwitchTo().Frame(frameElements[i]));
                        element = Driver.FindElement(ByElementName(elementType, nameToFind));
                        break;
                    }
                    catch (NoSuchElementException)
                    {
                        continue;
                    }

                }
            }

            if (element == null)
            {
                throw new NoSuchElementException();
            }
            else
            {
                return element;
            }
        }

```
#### タイムアウトの設定

```C#:
        /// <summary>
        /// WebDriverのInplicitTimeoutを設定する
        /// </summary>
        /// <param name="timeout">タイムアウト値（秒）</param>
        private bool SetTimeout(double timeout)
        {
            try
            {
                Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(timeout);
            }
            catch (Exception ex) when (ex is ArgumentException || ex is OverflowException)
            {
                return false;
            }
            return true;
        }
```

#### スクリーンショットをbitmapで取得

```C#:
        /// <summary>
        /// 取得したスクリーンショットをビットマップとして返す
        /// </summary>
        private Bitmap GetScreenshotAsBmp()
        {
            var byteImg = ((ITakesScreenshot)Driver).GetScreenshot().AsByteArray;
            Bitmap bmp;
            using (MemoryStream ms = new MemoryStream(byteImg))
            {
                bmp = new Bitmap(ms);
            }
            return bmp;
        }

```

#### Javascriptを使ってページロードを待つ

正しく動いているのかちょっと疑問ですが一応。
[StackOverFlowから拾ってきた](https://stackoverflow.com/questions/15122864/selenium-wait-until-document-is-ready)

```C#:
        /// <summary>
        /// Javascriptを使ってページロードの完了を待つ
        /// </summary>
        private bool WaitPageLoad()
        {
            var javaScriptExecutor = Driver as IJavaScriptExecutor;
            Func<IWebDriver, bool> readyCondition = webDriver => (bool)(javaScriptExecutor
                    .ExecuteScript("return (document.readyState == 'complete')"));
            return wait.Until(readyCondition);
        }
```

#### 指定した要素に移動する
モバイルのときにうまくいかない…。

```C#:
        /// <summary>
        /// 指定した要素に移動する
        /// </summary>
        private void MoveToElement(IWebElement element)
        {
            //要素の位置までスクロールする
            wait.Until(Drv => ((IJavaScriptExecutor)Drv).ExecuteScript("arguments[0].scrollIntoView(false);return true;", element));

            try
            {
                Actions actions = new Actions(Driver);
                actions.MoveToElement(element);
                actions.Perform();
            }
            catch (Exception ex)
            {   //モバイルの場合は下記のエラーになるので移動しない
                if (!ex.Message.StartsWith("An unknown server-side error occurred while processing the command. Original error: Error Domain=com.facebook.WebDriverAgent Code=1 \"Unsupported origin type"))
                {
                    throw;
                }
            }
        }
```

#### 画面全体のスクリーンショットを取得

スクロールで動的にメニューが変化する場合や上部に固定のメニューフレームがあるパターンには対応していません。

参考にさせて頂いた記事：
[Chromeでも画面全体のキャプチャ―を取得する](https://qiita.com/Akihisa-Tokuda/items/468c071b7229264baa62)
[【C#】画像の結合](https://imagingsolution.net/program/csharp/image_combine/) (Util.ImageCombineVはこの記事をそのままコピペ)

```C#:
            decimal innerH = 0;
            decimal innerW = 0;
            decimal scrollH = 0;
            innerH = Int32.Parse(jsDriver.ExecuteScript("return window.innerHeight").ToString());
            innerW = Int32.Parse(jsDriver.ExecuteScript("return window.innerWidth").ToString());
            scrollH = Int32.Parse(jsDriver.ExecuteScript("return document.documentElement.scrollHeight").ToString());

            string filePath = "スクリーンショットを保存したいパス";

            wait.Until(Drv => Drv.SwitchTo().DefaultContent());

            if (innerH > scrollH)
            {
                ((ITakesScreenshot)Driver).GetScreenshot().SaveAsFile(filePath, ScreenshotImageFormat.Png);
            }
            else
            {
                decimal repeat = Math.Ceiling((scrollH / innerH));
                decimal duplH = Math.Abs(scrollH - (innerH * repeat));

                List<Bitmap> screenshots = new List<Bitmap>();
                for (int i = 0; i < repeat; i++)
                {
                    wait.Until(Drv => ((IJavaScriptExecutor)Drv).ExecuteScript("window.scrollTo(0," + innerH * i + ");return true;"));
                    //ちょっと待たないとスクロールが間に合わない
                    Thread.Sleep(100);
                    screenshots.Add(GetScreenshotAsBmp());
                }

                //最後の画像は重複部分を切り取る
                Bitmap lastSc = screenshots.Last();

                //画像の縦横のサイズとJavascriptで取得した幅と高さから比率計算し、切り取るべきサイズを算出する
                decimal bitmapWidth = lastSc.Width;
                decimal bitmapHeight = lastSc.Height;

                decimal ratioWidth = innerW / bitmapWidth;
                decimal ratioHeight = innerH / bitmapHeight;

                int duplHeight = (int)Math.Round(duplH / ratioHeight, 0, MidpointRounding.AwayFromZero);
                int cutWidth = (int)Math.Round(innerW / ratioWidth, 0, MidpointRounding.AwayFromZero);
                int cutHeight = (int)Math.Round((innerH - duplH) / ratioHeight, 0, MidpointRounding.AwayFromZero);

                //算出したサイズで切り出す
                Rectangle rect = new Rectangle(0, duplHeight, cutWidth, cutHeight);
                screenshots.RemoveAt(screenshots.Count - 1);
                screenshots.Add(lastSc.Clone(rect, lastSc.PixelFormat));

                Bitmap screenshot = Util.ImageCombineV(screenshots.ToArray<Bitmap>());
                screenshot.Save(filePath);
            }

```

#### 通常では変更できない要素の値を強制的に書き換える

```C#:
            IWebElement element = Driver.FindElement(ByElementName(elementType, nameToFind));
            jsDriver.ExecuteScript("arguments[0].value = '" + Value + "';", element);
```

#### 通常では変更できない要素のテキストを強制的に書き換える

```C#:
            IWebElement element = Driver.FindElement(ByElementName(elementType, nameToFind));
            jsDriver.ExecuteScript("arguments[0].innerHTML = '" + Text + "';", element);
```