Selenium(Java)でE2Eテスト環境を構築する

~~18/11/29
Selenideを最新版（ver5系）にするとうまく動きません。
最新版を利用した方が良いので後程修正します。~~
18/12/03
Chromeのみ試し、プログラムコードを更新しました。
capabilities.jsonの記述はそのままIEなどが残っています。

# はじめに

即席QAです:innocent:
リグレッションテストに時間を取られている現状を少しずつ改善するため、繰り返し同じことを行っているシステムテストの自動化を行うべく環境構築を行いました。
その記録をしています。

テスト実行の方法は[Pitaliumのチュートリアル](https://www.htmlhifive.com/conts/web/view/pitalium/#H30C130E530FC30C830EA30A230EB)を参照した方が良いので記録していません。


# 利用したソフトウェア

検証するときに利用したソフトウェアです。どれも素晴らしかったです。
Pitaliumを利用してSelenium Grid（Hub、Node構成）でテストが行えます。
Windowsで検証構築を構築しています。

|ソフト|バージョン|用途|
|:--|:--|:--|
|java|1.8.0_191||
|[Pitalium](https://www.htmlhifive.com/conts/web/view/pitalium/)|1.2.4|ベースとして利用|
|[Selenide](http://selenide.org/)|5.0.1|PageObjectでのみ利用|
|[selenium-server-standalone.jar](https://www.seleniumhq.org/)|3.141.59||
|chromedriver.exe|2.44||


# 環境構築

[Pitaliumのチュートリアル](https://www.htmlhifive.com/conts/web/view/pitalium/#H30C130E530FC30C830EA30A230EB)を進め、テスト実行環境を構築します。

18/04/23
CI(Jenkins)でClassNotFoundが発生することがあるので全てMavenに変更しました。
scopeがtestではないのは、テスト用のプロジェクトで試しているからです。

###### Mavenファイル
<details><summary>Mavenファイル（展開）</summary><div>

```xml:pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>Autotest</groupId>
  <artifactId>Autotest</artifactId>
  <version>0.0.1</version>
  <build>
    <sourceDirectory>src</sourceDirectory>
    <testSourceDirectory>src</testSourceDirectory>
    <resources>
      <resource>
        <directory>resource</directory>
      </resource>
    </resources>
    <testResources>
      <testResource>
        <directory>resource</directory>
      </testResource>
    </testResources>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.3</version>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.12.4</version>
      </plugin>
    </plugins>
  </build>
  <dependencies>
  	<dependency>
  		<groupId>com.htmlhifive</groupId>
  		<artifactId>pitalium</artifactId>
  		<version>1.2.4</version>
  		<exclusions>
  			<exclusion>
  				<groupId>org.slf4j</groupId>
  				<artifactId>slf4j-api</artifactId>
  			</exclusion>
  		</exclusions>
  	</dependency>
  	<dependency>
  		<groupId>com.codeborne</groupId>
  		<artifactId>selenide</artifactId>
  		<version>5.0.1</version>
  		<exclusions>
  			<exclusion>
  				<groupId>org.apache.httpcomponents</groupId>
  				<artifactId>httpclient</artifactId>
  			</exclusion>
  			<exclusion>
  				<groupId>org.slf4j</groupId>
  				<artifactId>slf4j-api</artifactId>
  			</exclusion>
  		</exclusions>
  	</dependency>
  	<dependency>
  		<groupId>org.seleniumhq.selenium</groupId>
  		<artifactId>selenium-api</artifactId>
  		<version>3.141.59</version>
  	</dependency>
  	<dependency>
  		<groupId>org.seleniumhq.selenium</groupId>
  		<artifactId>selenium-remote-driver</artifactId>
  		<version>3.141.59</version>
  	</dependency>
  	<dependency>
  		<groupId>org.seleniumhq.selenium</groupId>
  		<artifactId>selenium-support</artifactId>
  		<version>3.141.59</version>
  	</dependency>
  	<dependency>
  		<groupId>com.google.guava</groupId>
  		<artifactId>guava</artifactId>
  		<version>27.0.1-jre</version>
  	</dependency>
  	<dependency>
  		<groupId>org.slf4j</groupId>
  		<artifactId>slf4j-log4j12</artifactId>
  		<version>1.8.0-beta2</version>
  	</dependency>
  	<dependency>
  		<groupId>org.apache.httpcomponents</groupId>
  		<artifactId>httpclient</artifactId>
  		<version>4.5.6</version>
  	</dependency>
  </dependencies>
  <properties>
    <java.version>1.8</java.version>
    <file.encoding>UTF-8</file.encoding>
  	<project.build.sourceEncoding>${file.encoding}</project.build.sourceEncoding>
  	<project.reporting.outputEncoding>${file.encoding}</project.reporting.outputEncoding>
    <maven.compiler.encoding>${file.encoding}</maven.compiler.encoding>
    <maven.compiler.source>${java.version}</maven.compiler.source>
    <maven.compiler.target>${java.version}</maven.compiler.target>
  </properties>
</project>
```

</div></details>


私の環境は以下のようになりました。
IEは後述する問題で最終的には別ノードに分けて立ち上げることになりました。

###### Hub起動コマンド
<details><summary>Hub起動コマンド（展開）</summary><div>

```console
java -jar selenium-server-standalone-3.141.59.jar -role hub
```

</div></details>

###### Node設定ファイル
<details><summary>Node設定ファイル（展開）</summary><div>

```json:NodeConfigBrowser.json
{
 "capabilities": [
    {
     "platform": "WINDOWS",
     "browserName": "firefox",
     "maxInstances": 2,
     "seleniumProtocol": "WebDriver"
    },
    {
     "platform": "WINDOWS",
     "browserName": "chrome",
     "maxInstances": 3,
     "seleniumProtocol": "WebDriver"
    },
    {
     "platform": "WINDOWS",
     "browserName": "internet explorer",
     "maxInstances": 1,
     "seleniumProtocol": "WebDriver"
    }
  ],
 "hub": "http://XXX.XXX.XXX.XXX:4444/grid/register",
 "register": true
}
```

</div></details>

###### Node起動コマンド
<details><summary>Node起動コマンド（展開）</summary><div>

```console
java -Dwebdriver.ie.driver=IEDriverServer.exe -Dwebdriver.chrome.driver=chromedriver.exe -Dwebdriver.gecko.driver=geckodriver.exe -jar selenium-server-standalone-3.141.59.jar -role node -nodeConfig NodeConfigBrowser.json
```

</div></details>


# プログラム構成

ディレクトリ構成は変わるかもしれませんが、とりあえず以下のような構成で作りました。

![aWS050227.JPG](https://qiita-image-store.s3.amazonaws.com/0/187061/21c5c011-b225-6bcc-4536-af3749533dd0.jpeg)

- pageパッケージ

PageObjectパターンという響きに惹かれて、1画面を1オブジェクトでとりあえず作ることに。
ここで Selenide を利用しています。

~~WebDriverの生成は Pitalium に任せる形にするので、親クラスを作成しコンストラクタでSelenide に WebDriver を渡す作りにしました。~~
18/12/03
Selenideの最新版（ver5系）はここで問題が発生していました。
テストケース親クラスで1回だけSelenideで利用するドライバを指定するよう変更しました。


###### 親Pageクラス
<details><summary>親Pageクラス（展開）</summary><div>

```java:PageBase.java
package page;

import com.htmlhifive.pitalium.core.config.PtlTestConfig;

/**
 * ページ内のヘッダーやフッターなど共通処理のページ親クラス
 */
public abstract class PageBase {

    /** httpやhttpsから始まるベースURL */
    protected final String _BASE_URL = PtlTestConfig.getInstance().getTestAppConfig().getBaseUrl();

    /*********************************
     * 共通操作
     *********************************/

}
```

</div></details>

###### 子Pageクラス
<details><summary>子Pageクラス（展開）</summary><div>

クラス名などを日本語にしている理由は後述。

```java:ログイン.java
package page;

import static com.codeborne.selenide.Selenide.*;

public class __ログイン画面 extends PageBase {

    public static final String _URL = "/login";

    /*********************************
     * セレクタ
     *********************************/

    // アカウントID
    private static final String _ACCOUNT_TXT_CSS = "#accountID";
    // パスワード
    private static final String _PASS_TXT_CSS = "#password";
    // ログインボタン
    private static final String _LOGIN_BTN_CSS = "#login";

    /*********************************
     * 操作
     *********************************/

    public __ログイン画面 __アカウントIDを入力(String s) {
        $(_ACCOUNT_TXT_CSS).setValue(s);
        return this;
    }

    public __ログイン画面 __パスワードを入力(String s) {
        $(_PASS_TXT_CSS).setValue(s);
        return this;
    }

    public __ログイン画面 __ログインボタンをクリック() {
        $(_LOGIN_BTN_CSS).click();
        return this;
    }

}
```

</div></details>

- testパッケージ

Pitalium のクラスを継承して作成しました。
テストの操作と画像比較を行っています。

ここでもベースとする親クラスを間にとりあえず入れることに。

###### 親testクラス
<details><summary>親testクラス（展開）</summary><div>

```java:TestBase.java
package test;

import java.util.Locale;

import org.junit.Before;
import org.junit.Rule;

import com.codeborne.selenide.Configuration;
import com.codeborne.selenide.WebDriverRunner;
import com.codeborne.selenide.commands.Commands;
import com.codeborne.selenide.junit.TextReport;
import com.google.common.base.Strings;
import com.htmlhifive.pitalium.core.PtlTestBase;
import com.htmlhifive.pitalium.core.model.ScreenshotArgument;

import custom.SetValueCustomize;

public abstract class TestBase extends PtlTestBase {

    /** Selenide性能レポート */
    @Rule
    public TextReport report = new TextReport();

    private int _ssCount = 1;

    @Override
    @Before
    public void setUp() {
        super.setUp();

        // IE用の処理を追加したコマンドに置き換え
        Commands.getInstance().add("setValue", new SetValueCustomize(Strings.nullToEmpty(capabilities.getBrowserName()).toLowerCase(Locale.ENGLISH)));

        // 要素検索タイムアウトを10秒に設定し、Selenideのドライバに設定
        Configuration.timeout = 10000;
        WebDriverRunner.setWebDriver(driver);

        // デフォルトは性能レポート未出力
        report.onSucceededTest(false);
        report.onFailedTest(false);

        // テスト実行前処理
        init();
    }

    /**
     * テスト実行前処理
     */
    protected void init() {};

    /**
     * Selenide性能レポートを有効化
     */
    protected void enablePerfReport() {
        report.onSucceededTest(true);
        report.onFailedTest(true);
    }

    /**
     * Chromeブラウザか判定
     * @return true:Chrome
     */
    protected boolean isChrome(){
        return "chrome".equals(Strings.nullToEmpty(capabilities.getBrowserName()).toLowerCase(Locale.ENGLISH)) ? true : false;
    }

    /**
     * Edgeブラウザか判定
     * @return Edge
     */
    protected boolean isEdge(){
        return "MicrosoftEdge".equals(Strings.nullToEmpty(capabilities.getBrowserName()).toLowerCase(Locale.ENGLISH)) ? true : false;
    }

    /**
     * IEブラウザか判定
     * @return IE
     */
    protected boolean isIE(){
        return "internet explorer".equals(Strings.nullToEmpty(capabilities.getBrowserName()).toLowerCase(Locale.ENGLISH)) ? true : false;
    }

    /**
     * スクリーンショットを取得する
     */
    protected void screenShotAndVerifyView() {
        screenShotAndVerifyView("image");
    }

    /**
     * スクリーンショットを取得する
     * @param スクリーンショット名
     */
    protected void screenShotAndVerifyView(String screenName) {
        ScreenshotArgument arg = ScreenshotArgument.builder(screenName + _ssCount).addNewTarget().build();
        assertionView.verifyView(arg);
        _ssCount++;
    }

}
```

</div></details>

###### 子testクラス
<details><summary>子testクラス（展開）</summary><div>

```java:LoginTest.java
package test;

import org.junit.Test;

import page.__ログイン画面;

public class LoginTest extends TestBase {
    @Test
    public void loginOK() throws Exception {
        driver.get(__ログイン画面 ._url);
        __ログイン画面 page = new __ログイン画面();

        page.__アカウントIDを入力("user")
                .__パスワードを入力("password")
                .__ログインボタンをクリック();

        assertionView.assertView("loginOK");
    }
}
```

</div></details>


# 気を付けたこと

テスト部隊は横文字というだけで難色を示す人もいるので、画面の操作にかかわるクラスやメソッドは全て日本語表記とすることにしました。

また、画面の操作メソッドもアンダーバー2つで簡単に候補を絞り込めるような作りとしました。

品質でコーディング経験がある人がいないので敷居が高いかなと思い、pageクラスは開発で行うよう進めたい。

pageクラス：開発がコード作成
testクラス：品質＆テスト部隊がコード作成


# 困ったこと

やりたい操作に対してそこまで困ったことはなかった気がする:hugging:
ただしIE、テメーはダメだ。

### IEでテキストの入力がうまくいかない

理由はわからないがIEでのみ入力が失敗する現象が発生。
IEの時は事前にコントロールを置いた方が良いとかクリックがうんたらなど情報が色々あり、わけわからなかったので全部やってしまうことに:rolling_eyes:
Selenideで入力する処理をhook（正確には入れ替えかも）して対応。

###### カスタムコマンドクラス
<details><summary>カスタムコマンドクラス（展開）</summary><div>

```java:SetValueCustomize.java
package common;

import java.io.IOException;

import org.openqa.selenium.Keys;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;

import com.codeborne.selenide.Configuration;
import com.codeborne.selenide.SelenideElement;
import com.codeborne.selenide.commands.Commands;
import com.codeborne.selenide.commands.SetValue;
import com.codeborne.selenide.impl.WebElementSource;

/**
 * SetValueの入れ替えクラス
 * 違いはIE個別の対応
 */
public class SetValueCustomize extends SetValue {

    private boolean _ieFlg;

    public SetValueCustomize(String _browserName) {
        _ieFlg = "internet explorer".equals(_browserName) ? true : false;
    }

    @Override
    public WebElement execute(SelenideElement proxy, WebElementSource locator, Object[] args) {
        WebElement element = locator.findAndAssertElementIsVisible();

        if (Configuration.versatileSetValue
                && "select".equalsIgnoreCase(element.getTagName())) {
            return super.execute(proxy, locator, args);
        }
        if (Configuration.versatileSetValue
                && "input".equalsIgnoreCase(element.getTagName()) && "radio".equals(element.getAttribute("type"))) {
            return super.execute(proxy, locator, args);
        }

        if (_ieFlg) {
            element.sendKeys(Keys.CONTROL);
            try {
                Commands.getInstance().execute(proxy, locator, "click", null);
            } catch (IOException e) {
                throw new NoSuchElementException("IE Click error in SetValueCustomize", e);
            }
        }

        return super.execute(proxy, locator, args);
    }
}
```

</div></details>

作成したコマンドを親テストクラスで事前に入れ替えます。

```java:TestBase.java

public abstract class TestBase extends PtlTestBase {
    @Override
    @Before
    public void setUp() {
        super.setUp();
        // IE用の処理を追加したコマンドに置き換え
        Commands.getInstance().add("setValue", new SetValueCustomize(Strings.nullToEmpty(capabilities.getBrowserName()).toLowerCase(Locale.ENGLISH)));
    }
}
```

各種pageクラスでsetValueメソッドを呼ぶと、Selenide内部でCommandsのMapから指定されたメソッド名に紐付くクラスを取得し、executeが呼び出されます。

```java:ログイン.java

public class ログイン extends PageBase {

    public ログイン __ログインIDを入力(String id) {
        $("#account").setValue(id);
        return this;
    }
}
```


### IEでHoverメニューが0.1秒（体感）しか表示されず子要素が選択できない

マウスをそのまま置いといてくれ！っと叫んでも解決しなそうだったので、capabilitiesに se:ieOptions で指定することで対応。

###### capabilities ieOptions追加版
<details><summary>capabilities.json</summary><div>

```json:capabilities.json
[
 {
  "browserName": "internet explorer",
  "se:ieOptions" : {
    "enablePersistentHover" : true,
    "requireWindowFocus" : true
  }
 }
]
```

</div></details>

これらのことによりIE専用でかつIEが1プロセスしか起動せず、他者の操作やリモートを許さないNode（端末）を用意することに勝手に決定:innocent:

###### IE専用端末のNodeConfig
<details><summary>IE専用端末のNodeConfig</summary><div>

```json:NodeConfigBrowser.json
{
 "capabilities": [
    {
     "platform": "WINDOWS",
     "browserName": "internet explorer",
     "maxInstances": 1,
     "seleniumProtocol": "WebDriver"
    }
  ],
 "hub": "http://XXXXX:4444/grid/register",
 "register": true
}
```

</div></details>


### IE、Firefoxでダウンロード時にダイアログなどが表示され操作が効かなくなる（未解決　*WinAppDriverを利用すれば解決できる可能性あり。*）

Firefoxはcapabilitiesに moz:firefoxOptions - prefs で設定してみたが、うまくいかないのでChrome以外でダウンロードテストしないと勝手に決定:innocent:

###### capabilities firefoxOptions追加版（意味なかった）
<details><summary>(意味なかった)capabilities.json</summary><div>

```json:capabilities.json
[
 {
  "browserName": "firefox",
  "moz:firefoxOptions": {
    "prefs": {
        "browser.download.folderList": 0,
        "browser.download.useDownloadDir": true,
        "browser.helperApps.neverAsk.saveToDisk": "text/*,application/*",
        "browser.helperApps.alwaysAsk.force": false,
        "browser.download.manager.closeWhenDone": true,
        "pdfjs.enabledCache.state": false
    }
  }
 }
]
```

</div></details>