PytestとSeleniumでE2Eテストを自動化する

## 背景

チームにてWebアプリを開発する際に、別のメンバーのコミットによってこれまでの機能に不具合が生じてしまう現象が起こり得ます(Degradation; デグレ)。自分のコミットをマージしてもらう前にE2Eテストを流して、以前の機能に影響がないことを確かめることによりデグレの発生確率を抑えることができます。  

今回はPythonのテストフレームワークである `Pytest` とブラウザの自動操作ツールである `Selenium` のPythonライブラリを用いて E2Eテストの自動化を試してみます。

- ソースコードは弊社でインターンシップとして参加してくれている [@marufura](https://github.com/marufura) が協力してくれました。


## 環境

- MacBook Air (M1, 2020)
- Python 3.9.13
  - Rosetta提供のIntel環境にてビルドされたPythonを利用しています.
- Chrome
  - バージョン: 107.0.5304.110（Official Build） （arm64）
  -  対象バージョンの`chromedriver` をダウンロードしておきます.
    -  https://chromedriver.storage.googleapis.com/index.html?path=107.0.5304.62/

- 以下のコマンドで依存ライブラリをダウンロードします.

```bash
pip install selenium pytest python-dotenv
```

## Seleniumとは

- ブラウザをコードベースで自動で動かすためのツールです。
- 基本的には DOMの要素を タグやIDなどで検索して、その要素に正しい値が入っているかをチェックする、というようなテストを各ページにわたって行います。
- web driverというオブジェクトのインターフェースを使ってページ遷移したり、DOMにアクセスしたりします。


## Pytestとは

サードパーティ製のテスト用フレームワークです。pipでインストールすることで `pytest` コマンドが使えるようになり、これ一発叩くことで `test_` というprefixのついたファイルおよび関数を自動で探索して、実行してくれます。

### Fixtureを使う

- `fixture` はpytestに具備されている機能で、簡単にいうと **全てのテストで共通して利用するリソースを呼び出すための仕組み** と考えてもらえればOKです。  
- `conftest.py` というファイルにfixtureの定義を書いておくことで、どのテストファイルからも共通して呼び出すことができます.
- 今回はこのfixtureを使って、web driverをどのテストからも呼び出せるようにします。こうすることでweb driverをテストごとに生成する必要がなくなり、効率的にテストを進めることができます。
- 以下に  `conftest.py` のサンプルを書いてみます。

```py
"""
pytest 実行時に必ず利用されるfixtureをまとめる
"""

import pytest
import os
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.chrome import service as chrome_fs
from selenium.webdriver.edge import service as edge_fs
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.edge.options import Options as EdgeOptions
from dotenv import load_dotenv


@pytest.fixture(scope="session")    # ここがポイント！！
def driver():
    """
    pytest session開始時に1回だけ呼ばれる, selenium driverを提供するためのfixture
    """

    load_dotenv()
    print("start loading driver...")
    DRIVER_PATH = os.getenv("DRIVER_PATH")
    assert DRIVER_PATH is not None, "driver path is not given."
    assert  Path(DRIVER_PATH).exists(), "driver file does not exist."
    assert Path(DRIVER_PATH).is_absolute(), "driver path must be given as absolute path."

    BROWSER_TYPE = os.getenv("BROWSER_TYPE")
    if BROWSER_TYPE == "chrome":
        browser_service = chrome_fs.Service(executable_path=DRIVER_PATH)
        options = ChromeOptions
    elif BROWSER_TYPE == "edge":
        browser_service = edge_fs.Service(executable_path=DRIVER_PATH)
        options = EdgeOptions
    else:
        print("warning: BROWSER_TYPE is not set, continueing test with Chrome driver.")
        browser_service = chrome_fs.Service(executable_path=DRIVER_PATH)

    # オプション設定
    options = options()
    print(os.getenv("IS_HEADLESS"))

    IS_HEADLESS = bool(int(os.getenv("IS_HEADLESS")))
    if IS_HEADLESS:
        print("continue in headless mode.")
        options.add_argument("--headless")
        options.add_argument("--disable-gpu")
        options.add_argument("--disable-desktop-notifications")
        options.add_argument("--disable-extensions")
    options.add_argument("--lang=ja")

    driver = webdriver.Edge(options=options, service=browser_service)
    driver.implicitly_wait(10)

    print("finish loading driver.")

    yield driver    # ここがポイント2!!

    print("driver closed.")
    driver.quit()


@pytest.fixture(scope="session")
def url():
    load_dotenv()
    BASE_URL = os.getenv("BASE_URL")
    assert BASE_URL is not None, "base url is not given."
    yield BASE_URL

```

- **ここがポイント!!** とコメントしてある関数アノテーション `@pytest.fixture(scope="session") ` をみてください。このアノテーションをつけるだけで、対象の関数をfixtureとして登録することができます。
- **ここがポイント2!!** とコメントしてある `yield driver` をみてください。この関数の目的は、各テストによって共通して利用される webdriverオブジェクトを返すことです。`return` で返さずに `yield` を使うと、テストの処理が終了したのちに yieldより下の処理を行うことができます。つまりここでやっているのは、全てのテストが終了するまで `driver` オブジェクトを使い回し、全て終了したタイミングで `driver.close()` でwebdriverを終了する、ということです。


### テストコードを書いてみる

ではこの fixtureを利用するテストコードを実際に書いてみましょう。よくお世話になっている国土地理院のホームページを対象に、ページのタイトルが想定される文字列になっているかをチェックするテストを作ってみます。コードは以下のようになります。

```py
from time import sleep
from selenium.webdriver.common.by import By


def test_title_good(driver, url):
    """
    地理院ホームページのタイトルに設定してある文字列をテストします.

    args:
      - driver: fixtureによって提供されるweb driverオブジェクト
      - url: fixtureによって提供されるベースのURL (str)
    """
    # 目的のサイトを開きます
    driver.get(url)
    # タイトルの要素を取得します。
    title = driver.find_element(By.TAG_NAME, 'h1')

    # 正しいケース
    assert title.text == "国土を測る・描く・守る・伝える", "タイトルが不正です。"
    return


def test_title_bad(driver, url):
    # 目的のサイトを開きます
    driver.get(url)
    # タイトルの要素を取得します。
    title = driver.find_element(By.TAG_NAME, 'h1')

    # 不正なケース
    assert title.text == "国土を測る・書く・守る・伝える", "タイトルが不正です。"
    return


if __name__ == "__main__":
    test_title_good()

```

- まず関数名をみてください。 `test_title` としていますが、`test_` というprefixを付けることで pytestが自動で実行する対象として認識することができます。
- さらにこの関数は `driver` と `url` という2つの引数をとっていますが、これらは `conftest.py` にて定義した2つのfixtureです。このように引数にfixtureを取ることで、どのテストでも使いたいリソースを簡単に呼び出すことができます。
- このテストではDOMの `h1` というタグを取得してきて、その要素が持つテキストが `"国土を測る・描く・守る・伝える"` という文字列であることをテストしています。 `assert` 文を利用することで、期待される値ではなかった時にテストの失敗を伝えることができます。

### テストを実行する

- テストを実行する前に、設定ファイルを用意しましょう。
- いろんな環境で汎用的に使えるように `python-dotenv` というライブラリを利用して環境変数を `.env` というファイルに書き込んで、実行時に読み込むようにしています。

```
BROWSER_TYPE="chrome"    # 使いたいwebdriverの種類を chromeにしている
DRIVER_PATH="/Applications/chromedriver"    # chromedriverの絶対パス
IS_HEADLESS=0    # ヘッドレスモードで動かすかどうか (True=1, False=0)
BASE_URL="https://www.gsi.go.jp/"    # 国土地理院のURL
```

- この設定で実行します。

```bash
pytest . -s
```

- `-s` というオプションは標準出力(print) を表示するかどうかです. デバッグ用に表示させています。
- `.` は テストコードを探索するルートフォルダが現在の実行フォルダであることを示しています。ルートフォルダからサブフォルダを探索して行って、 `test_` というprefixを持つ関数を全て実行します。


### テスト結果を見る

実行すると、以下のようにコンソールに表示されます。

```
=============================================================== test session starts ================================================================
platform darwin -- Python 3.9.13, pytest-7.2.0, pluggy-1.0.0
collected 2 items                                                                                                                                  

test_home.py .F                                                                                                                              [100%]

===================================================================== FAILURES =====================================================================
__________________________________________________________________ test_title_bad __________________________________________________________________

driver = <selenium.webdriver.edge.webdriver.WebDriver (session="bc522a6a62566b38085d2a3b203ba65e")>, url = 'https://www.gsi.go.jp/'

    def test_title_bad(driver, url):
        # 目的のサイトを開きます
        driver.get(url)
        # タイトルの要素を取得します。
        title = driver.find_element(By.TAG_NAME, 'h1')
    
        # 不正なケース
>       assert title.text == "国土を測る・書く・守る・伝える", "タイトルが不正です。"
E       AssertionError: タイトルが不正です。
E       assert '国土を測る・描く・守る・伝える' == '国土を測る・書く・守る・伝える'
E         - 国土を測る・書く・守る・伝える
E         ?       ^
E         + 国土を測る・描く・守る・伝える
E         ?       ^

test_home.py:30: AssertionError
------------------------------------------------------------- Captured stdout teardown -------------------------------------------------------------
driver closed.

============================================================= short test summary info ==============================================================
FAILED test_home.py::test_title_bad - AssertionError: タイトルが不正です。
===================================================== 1 failed, 1 passed, 18 warnings in 7.49s =====================================================
```

- `1 failed, 1 passed, 18 warnings in 7.49s` が結果です。テストのうち失敗したものがいくつあったのか、統計をとってくれます。今回は想定通り、1つ成功、１つ失敗という結果になりました。
- 結果の上にはどこで失敗しているのかが表示されているので、もしテストが通らなかった場合に修正する箇所をすぐに特定することができます。


## まとめ

- Pytest と Seleniumを使ってE2Eテストを自動化することができました。
- テスト仕様書の構造に合わせてフォルダを構造化してリポジトリを整理するのがおすすめです。
- fixtureは webdriverの使い回しだけでなく データベースとのセッションを保持しておくのにも使えるので、テスト開始時にDBを初期化したりテストデータを入れたりする時にも便利です。色々カスタマイズしてみてください。