E2EテストをPageObjectパターンで実装する

E2Eテスト実装のベストプラクティスとされているPageObjectパターンを試してみました。

## テストケース

Djangoの管理画面からユーザ作成を行うテストを実装しました。
流れは次のとおりです。

1. ログイン画面でユーザ名、パスワードを入力して[ログイン]をクリック
1. 管理画面でユーザーの[+追加]をクリック
1. ユーザ管理画面でユーザ名、パスワード、パスワードの確認を入力して[保存]をクリック
1. ユーザ管理画面にユーザ作成完了のメッセージが表示されていることをテスト

## PageObjectの実装

PageObjectはページ単位で作成するので、今回のケースだと次のPageObjectが必要になります。

- `LoginPage`
    - ログイン画面
- `AdminPage`
    - ログイン後に表示される管理画面
- `AddUserPage`
    - 管理画面でユーザーの[+追加]をクリックした後に表示されるユーザ追加画面
- `UserPage`
    - ユーザ追加完了後に表示される画面

ページ内で実行する操作をメソッドとして実装しますが、フォームへの入力、クリックなどの細かい単位ではなく、ある程度まとまったタスクをメソッドにします。
具体的には次のようにしました。

- `LoginPage`
    - `login`メソッド
        - ユーザ名、パスワードを入力して[ログイン]をクリック
- `AdminPage`
    - `go_to_add_user_page`メソッド
        - ユーザーの[+追加]をクリック
- `AddUserPage`
    - `add_user`メソッド
        - ユーザ名、パスワード、パスワードの確認を入力して[保存]をクリック
- `UserPage`
    - 今回は特に操作しないのでメソッドはなし

## コード

実装したコードは次のとおりです。以下のリポジトリにも同じコードがあります。
https://github.com/shiimaxx/page-object-pattern-example

### ログインページ

```login.py
from pages.admin import AdminPage


class LoginPage(object):

    def __init__(self, driver):
        self.driver = driver

    def login(self, username, password):
        login_form = self.driver.find_element_by_id('login-form')
        username_ = login_form.find_element_by_name('username')
        password_ = login_form.find_element_by_name('password')
        button = login_form.find_element_by_css_selector('input[value="ログイン"]')
        username_.send_keys(username)
        password_.send_keys(password)
        button.click()
        return AdminPage(self.driver)
```

### 管理ページ

```admin.py
from pages.user import AddUserPage


class AdminPage():

    def __init__(self, driver):
        self.driver = driver

    def go_to_add_user_page(self):
        self.driver.find_element_by_css_selector('a[href="/admin/auth/user/add/"]').click()
        return AddUserPage(self.driver)
```

### ユーザ管理ページ

```user.py
class AddUserPage(object):

    def __init__(self, driver):
        self.driver = driver

    def add_user(self, username, password):
        user_form = self.driver.find_element_by_id('user_form')
        username_ = user_form.find_element_by_name('username')
        password1 = user_form.find_element_by_name('password1')
        password2 = user_form.find_element_by_name('password2')
        button = user_form.find_element_by_name('_save')
        username_.send_keys(username)
        password1.send_keys(password)
        password2.send_keys(password)
        button.click()
        return UserPage(self.driver)


class UserPage(object):

    def __init__(self, driver):
        self.driver = driver
```

### テスト

```test_django_admin.py
import unittest

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

from pages.login import LoginPage


class TestDjangoAdmin(unittest.TestCase):

    def setUp(self):
        options = Options()
        options.binary_location = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
        options.add_argument('--headless')
        options.add_argument('--disable-gpu')
        self.driver = webdriver.Chrome(chrome_options=options)
        self.driver = webdriver.Chrome()
        self.driver.implicitly_wait(5)
        self.driver.set_page_load_timeout(30)
        self.driver.set_window_size(1920, 1080)

    def test_add_user(self):
        self.driver.get('http://127.0.0.1:8000/admin')
        login_page = LoginPage(self.driver)
        admin_page = login_page.login('admin', 'p@ssword')
        add_user_page = admin_page.go_to_add_user_page()
        user_page = add_user_page.add_user('testuser', 'dummy_p@ssword')
        self.assertIn('testuser</a>" を追加しました。続けて編集できます。</li>', user_page.driver.page_source)

    def tearDown(self):
        self.driver.close()
```

## まとめ

今回は1つのテストケースしか実装しなかったのでPageObjectパターンの恩恵はそこまでないかもしれませんが、テストを運用していくことを想定すると、テストケースが読みやすかったり、PageObjectやメソッドの再利用によってテストケースが追加しやすくなっていたりなど、保守性は上がっている状態だと思います。

## 参考文献

- [github.com/SeleniumHQ/selenium/wiki/PageObjects](https://github.com/SeleniumHQ/selenium/wiki/PageObjects)
- [selenium-python.readthedocs.io - 6. Page Objects](http://selenium-python.readthedocs.io/page-objects.html)