Web UIテスト自動化の実行環境をSelenium Gridで

f:id:vasilyjp:20190108120735p:plain

どうも品質管理部エンジニアチームの木村です。
最近の話ではないんですがWeb UIテスト自動化をしようとなった時の事を書きます。 まずは初期段階の実行環境についてです、自動テストスクリプトの構築や処理そのものはまた次回。

Seleniumでテストを自動化したい!

ZOZOTOWN に限らず最近のサービスはなんでもリリース頻度が高いです。
そして何故なのか、いついかなる時も、開発スケジュールは押し気味になります。
これは業界七不思議の1つです。たぶん。
品質管理部としてのテストは開発スケジュールの一番最後に置かれます。
つまり…短期決戦必須となります…。

そんなよくある話からSeleniumを使ってWeb UIテストを自動にしたいという流れになりました。
リリース頻度が高ければ高いほど、リグレッションテストはおろそかになると思うので、そこを自動テストで改善できると素敵です。

じゃあ自動テストスクリプトは書くけれど、その前にどこで実行するの?
という事で、正直なところSeleniumもよくわかっていないところから、どうしたら良いのかを調べていました。

実行環境への要望としては下記の5点です。
並列処理でテスト実行時間を最大限短くしていきたい
作業中PCで自動テストを実行した時、ブラウザがピョコピョコ動いてるのは目障り
実行するPCによって環境がかわるのは避けたい
でも実行環境は気軽に変えられるようにしたい
実行する人数やテスト数が増えた時に環境を増強できるようにしたい

調べていると、Selenium Gridを使えば上記問題は全て解決できそうだとわかりました。

Selenium Grid / docker-selenium

Selenium
ブラウザ上でのwebページ操作をスクリプトから行うためのツールです。
https://www.seleniumhq.org/
http://www.selenium.jp/

Selenium Grid
Seleniumで実行される動作を管理するツールです。
https://www.seleniumhq.org/docs/07_selenium_grid.jsp

node内に起動するブラウザでテスト処理を実行し、hubがnodeを管理してテストスクリプトからの命令を仲介します。
nodeや、node内のブラウザを複数起動してもhubが接続を管理してくれるので並列処理なども簡単に行えます。
hubとnodeは分けて起動できるので、nodeの追加や変更時にhubやその他のnodeを止める事なく簡単に行えます。

またdocker-seleniumというDocker版SeleniumGridもありました。
Dockerのコンテナを使用するので、場所を選ばず、起動が手軽で、管理もしやすく、クジラも可愛いです。 Dockerよくわからないけど素敵です。 https://github.com/SeleniumHQ/docker-selenium

とりあえず作ってみた環境

f:id:vasilyjp:20190108122159p:plain

内容に合わせて3種類に分けました。
自動テストスクリプトを実行するPC
hubを起動するPC
nodeを起動するPC

自動テストスクリプトはhubの情報さえ持っておけば、nodeの起動場所を一切気にする必要なくテスト処理を行えるので、PCを分けても問題なく動いてくれます。
hubはhub専用、もしくは各テストスクリプトの設定データや結果等、自動テストスクリプトで使用される共有データを保管しておくのも良いかもしれません。簡易的な形でdbを入れてみたりとか。
nodeはnode専用にして、社内の片隅に放置されてる可哀想なPCを集めて使うのも優しい気持ちになれて良いかもしれません。モッタイナイ精神。
画像内に表示してるPCアイコンは適当に選んだだけなので、これらを使用しているわけではないです。

いざ環境作成

とりあえず1台のPC内で環境を作ってみます。
以降の環境作成を実際に試す時はDockerとPython 3.6をインストールしてください。今回使用していたPCは全てmacOS High Sierraです。
尚、以下の作成手順にはIPを書き込む部分がいくつかありますが、1台で動かしている間は全て同じIPです。

必要なファイルを全部まとめて作っておきます。

TestAutomation
|_hub
|  |_docker-compose.yml
|
|_node
|  |_docker-compose.yml
|
|_selenium_grid_setup.py
|
|_test_automation.py

hubの準備

dockerでhubを起動する為に、設定ファイルを作ります。

./hub/docker-selenium.yml

version: "3"
services:
  selenium-hub:
    image: selenium/hub:3.14.0-helium
    container_name: selenium-hub
    ports:
      - "4444:4444"
    environment:
    - GRID_BROWSER_TIMEOUT=120
    - GRID_TIMEOUT=150
    - GRID_MAX_SESSION=30

使用するselenium imageのバージョンは全て統一しないと動かなくなる時があるので、管理の事も考えてとりあえず書いておきます。

environmentのところで設定を変更できます。
GRID_BROWSER_TIMEOUTがブラウザのタイムアウト、クリック等で失敗を判断するまでの時間です。 GRID_TIMEOUTはhubがnodeを未使用の状態だ判断するまでの時間です。例えば自動テストスクリプトがエラーで終了した時、 hubに終了した事を伝えられないままになるのでnodeを掴んだまま解放されなくなります。なので、命令の無い状態が一定時間続いた時に解放するよ、という設定です。たぶん。 詳しくは公式で
https://github.com/SeleniumHQ/docker-selenium/blob/master/Hub/Dockerfile.txt

ymlファイルを作ったら起動します。

mac:TestAutomation user$ docker-compose -f hub/docker-compose.yml up -d

URLでアクセスできたら成功です。
http://<hub用PCのIP>:4444/grid/console

nodeの準備

hubと同様にnode用の設定ファイルを作ります。

./node/docker-selenium

version: "3"
services:
   browser_0:
       image: selenium/node-chrome-debug:3.14.0-helium
       container_name: chrome0
       ports:
            - "55550:5900"
            - "5555:5555"
       environment:
            - NODE_MAX_INSTANCES=5
            - NODE_MAX_SESSION=5
            - HUB_PORT_4444_TCP_ADDR=<hub用PCのIP>
            - HUB_PORT_4444_TCP_PORT=4444
            - REMOTE_HOST="http://<node用PCのIP>:5555"
   browser_1:
       image: selenium/node-chrome-debug:3.14.0-helium
       container_name: chrome1
       ports:
            - "55560:5900"
            - "5556:5555"
       environment:
            - NODE_MAX_INSTANCES=5
            - NODE_MAX_SESSION=5
            - HUB_PORT_4444_TCP_ADDR=<hub用PCのIP>
            - HUB_PORT_4444_TCP_PORT=4444
            - REMOTE_HOST="http://<node用PCのIP>:5556"

この設定ファイルでchromeを5つまで動かせるnodeを2つ起動できます。
browser_0とbrowser_1です。

portsを設定しておかないとホスト側のポートがランダムに選ばれるのでちゃんと書く必要があります。 左側がホストのポート、右側がコンテナのポートです。ホスト側のポートは空いているポートを指定してください。
portsの"5555:5555"がhubからnodeへの命令時に使用されるポートで、"55550:5900"がvnc接続に使用するポートです。 自動テストスクリプトの作成中はvncで確認したい事があると思うので、ポート番号がわかりにくいと面倒です。 各nodeのホスト側ポート「5555」「5556」は気軽に確認できるので、そこから連想しやすい番号だと楽です。上に書いたファイルの場合は末尾に0をつける形です。

nodeのenvironmentでもいろいろと設定できます。
NODE_MAXは1つのnode内で起動しておくブラウザの個数や、同時に動かせるブラウザの個数です。
タイムアウト等様々な設定がありますが必要ないものはもちろん、自動テストスクリプトの中で切り替える事ができるものもあります。
特殊な使い方をしないのであればほどほどで良いのかなと思います。
困ったら足すくらいの流れで。
https://github.com/SeleniumHQ/docker-selenium/blob/master/NodeBase/Dockerfile.txt

ymlファイルをnodeフォルダに保存したら、起動します。

mac:TestAutomation user$ docker-compose -f node/docker-compose.yml up -d

もう一度アクセスしてみて表示内容が変わっていれば成功です。
http://<hub用PCのIP>:4444/grid/console
f:id:vasilyjp:20190108123420p:plain

自動テストスクリプトを実行した時にちゃんと動作しているかがわかりにくいので、vnc接続もしておきます。
Finder>移動>サーバーへ接続から下のアドレスを入力して接続です。vncのパスワードはsecretです。

vnc://<node用PCのIP>:55550  
vnc://<node用PCのIP>:55560  

Windowsの場合はUltraVNCとか入れると良いと思います。使い方は、わかりません!

必須ではないけれどnodeの起動方法

node起動時はnode数・使用ブラウザ・使用port等の設定内容を変更しながら試していたので、その都度ファイルを書き換えるのは面倒でした。 なので「ymlファイルを作ってからnodeを起動する」というスクリプトを書く形にしました。 node毎に設定するportはSTART_PORTで決めています。1つ目のnodeは5555、2つ目は5556といった形になっています。

./selenium_grid_setup.py

import os

NODE_MAX = 5
START_PORT = 5555


def get_yml_text(node_count, hubip, myip, image, browser_name):
    text = 'version: "3"\nservices:\n'

    for i in range(node_count):
        text += '   browser_{num}:\n'\
                '       image: selenium/{image}\n'\
                '       container_name: {browser_name}{num}\n'\
                '       ports:\n'\
                '            - "{port}0:5900"\n'\
                '            - "{port}:5555"\n'\
                '       volumes:\n'\
                '            - /dev/shm:/dev/shm\n'\
                '       environment:\n'\
                '            - NODE_MAX_INSTANCES={NODE_MAX}\n'\
                '            - NODE_MAX_SESSION={NODE_MAX}\n'\
                '            - HUB_PORT_4444_TCP_ADDR={hubip}\n'\
                '            - HUB_PORT_4444_TCP_PORT=4444\n'\
                '            - REMOTE_HOST="http://{myip}:{port}"\n'.format(
                NODE_MAX=NODE_MAX,
                image=image,
                browser_name=browser_name,
                hubip=hubip,
                myip=myip,
                num=str(i),
                port=str(START_PORT + i)
                )

    return text


def create_file(name, text):
    f = open(name, 'w')
    f.write(text)
    f.close()


def launch_node():
    hubip = '<hub用PCのIP>'
    myip = '<自身のIP>'
    node_count = 2
    image = 'node-chrome-debug:3.14.0-helium'
    browser_name = 'chrome'

    yml_text = get_yml_text(node_count=node_count,
                            hubip=hubip, myip=myip,
                            image=image, browser_name=browser_name)

    yml_file_name = 'node/docker-compose.yml'
    create_file(yml_file_name, yml_text)

    os.system('docker-compose -f node/docker-compose.yml up -d')


# 作る前に停止
os.system('docker-compose -f node/docker-compose.yml down')

launch_node()

ちょっと適当な感じが滲み出ちゃってますが、こんな形で作りたい内容に合わせてymlファイルを作って保存します。 yml設定ファイルを上書きする前に、古いyml設定ファイルで起動されているコンテナを停止するのだけ忘れないように。忘れるとコンテナで溢れかえります。

どうせ作るならhubの設定ファイルもスクリプトで作るようにするのが良いですね。imageのバージョンを1箇所で管理したいですし。
あと、流石に直接Pythonで文字列を作るより、PyYAMLを使うのが綺麗かもしれないですね。何も考えずに書いても長さは特に変わっていませんが。

import yaml
from collections import OrderedDict


# dict だと並び順が変わってしまったのでOrderedDictを使いました
# OrderedDict だと出力の形式が違ったのですがyaml.add_representerで設定を変更できるみたいです
def represent_odict(dumper, instance):
    return dumper.represent_mapping('tag:yaml.org,2002:map', instance.items())

yaml.add_representer(OrderedDict, represent_odict)


# get_yml_text を改変します
def get_yml_od(node_count, hubip, myip, image, browser_name):
    services_dict = OrderedDict()
    for i in range(node_count):
        num = str(i)
        port = str(5555 + i)

        services_dict[f'browser_{num}'] = OrderedDict([
             ('image', f'selenium/{image}'),
             ('container_name', f'{browser_name}{num}'),
             ('ports', [
                 f'{port}0:5900',
                 f'{port}:5555'
             ]),
             ('volumes', [
                f'/dev/shm:/dev/shm'
             ]),
             ('environment', [
                f'NODE_MAX_INSTANCES=5',
                f'NODE_MAX_SESSION=5',
                f'HUB_PORT_4444_TCP_ADDR={hubip}',
                f'HUB_PORT_4444_TCP_PORT=4444',
                f'REMOTE_HOST="http://{myip}:{port}"'
             ])
        ])

    return OrderedDict([('version', '3'), ('services', services_dict)])


# yaml の為にちょっと設定を追加します
def create_file(name, yml_dct):
    with open(name, 'w') as file:
        yaml.dump(yml_dct, file, default_flow_style=False)


# 引数は適当です
od = get_yml_od(node_count=2, hubip='0.0.0.0', myip='1.1.1.1', image='node-chrome-debug:3.14.0-helium', browser_name='chrome')
create_file('node/docker-compose.yml', od)

実行してみる

テスト処理を行うスクリプトを作って、実行します。
JavaとPythonでは書いてましたが、RubyやJavaScriptでは書いた事ないです。いろんな言語でかけるみたいです。

./test_automation.py

from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities


def get_web_driver():
    return webdriver.Remote(
        command_executor=f'http://<hub用PCのIP>:4444/wd/hub',
        desired_capabilities=DesiredCapabilities.CHROME
    )

driver = get_web_driver()
driver.get('https://www.google.com')
driver.quit()

↓せっかくなのでJavaの場合

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import java.net.URL;


public class Main {
    public static WebDriver getWebDriver(){
        try{
            return new RemoteWebDriver(
                new URL("http://<hub用PCのIP>:4444/wd/hub"),
                DesiredCapabilities.chrome());
        }catch (Exception e){
            System.out.println(e);
            return null
        }
    }
    public static void main(String args[]){
        WebDriver driver = getWebDriver();
        driver.get("https://www.google.com");
        driver.quit();
    }

googleを開いて、ブラウザを閉じるだけです。
スクリプトが動いてくれたら成功です。

ここまで1台のPCで動かしていたので、次はPCを3台にわけてみます。
分ける前に、今現在立ち上がってるdockerのコンテナを全て停止しておきます。

mac:TestAutomation user$ docker-compose -f node/docker-compose.yml down
mac:TestAutomation user$ docker-compose -f hub/docker-compose.yml down

「実行用PC」「hub用PC」「node用PC」の3台にわける

追加するPCのdockerインストールやPythonのバージョンにご注意ください。

ここまで書いたファイルの<hub用PCのIP><node用PCのIP>の部分を書き直します。
IPを書き直したら全てのファイルを3台のPCにコピー&ペーストします。コピペなんてやってられるか!Gitだ!Git! と、おわかりの方はGitよろしくです。
あと、実際の運用を考えると、別途設定ファイルを用意して置いてそこにIPを書いて置いた方が切り替えやすいので良いですね。

各PCにファイルを揃えたらこれまでの手順通りに進めます。
1.hub用PCでhubを起動
2.node用PCでnodeを起動
3.起動したnodeにvnc接続
4.最後に実行用PCで自動テストスクリプトを実行

動いてくれたら完成です。もしも動かないという時は各IPを確認してください。
あとはhub用PCにも追加でnodeを起動してみたり、さらに4台目のPCを用意してnodeを追加してみたり、hubを複数にしてみたり。

使用中に起こった困った事

selenium使用中に発生した環境に関する問題で思い出せた事例を書いておきます。

<困ったこと1>

自動テストスクリプトの中でWebDriverに対して何らかの命令を送った時に、実行されなくなった事がありました。

各箇所で使用しているseleniumのバージョンが異なる時に、問題が発生しやすいみたいです。 絶対問題が出るというわけではないので余計に見落とします。
docker-compose.ymlで指定してるhubやnode、自動テストスクリプト内で使用してるselenium、ローカル上のブラウザで動かす時はそのpcに入っているブラウザ等、それぞれのバージョンに気をつける必要がありそうです。

docker-compose.yml

version: "3"
services:
  selenium-hub:
    image: selenium/hub:3.14.0-helium

java - pom.xml

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>3.14.0</version>
</dependency>

Python - pip
ターミナルから「pip list」でインストールしているバージョンを確認できるので確認。

<困ったこと2>

連続したテスト実行中に、開始時は問題なかったのに途中からWebDriverを掴めなくなる事がありました。エラーを見るとhubへの接続が失敗しているような雰囲気でした。

自動テストスクリプトの中でWebDriverに対して何らかの操作を行うと、 作業pc → hub → nodeという流れで命令を送信します。hubは中間地点として大量の命令をさばいてます。 ウィルス/スパイウェアで聞いた事があるような「自動で大量のリクエストを送信する」という行為と同じようなことをやってます。
社内環境だとセキュリティがしっかりかかってる事も多いと思いますから、そのウィルスのような挙動が原因となってセキュリティソフトに接続をブロックされる場合がありました。 リクエストが多いと言っても抑えられる範囲なので、同時実行数を抑えたり、hubを分けて分散させたりで解決できると思います。もしくはセキュリティチームに相談したり。

おわり

ツールの使い方はいろんなところに書いてあるのですが、どんな環境・流れで動かしているか、どんなトラブルがあったかという記事は見当たらないことが多かったので書いてみました。 と言っても、まずは初期段階のSeleniumGrid環境についてでしたので基本的な事ばかりではありますが。

ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください。

www.starttoday-tech.com

カテゴリー