tofucodes diary

にほんごのほう

E2EテストのアサーションをテストコードとPageObjectどちらに持つべきかという話

E2EテストのデザインパターンとしてPageObjectというものがあります。 今回はPageObject自体の説明は省きますが、簡潔に述べるとWebページなどの詳細(idとかclassとかDOM階層とかとか)を隠して利用側(テストコード)が利用しやすいインターフェースを提供する役目という認識です私は。

より詳しい内容はマーティン・ファウラーのブログをご参照ください。

www.martinfowler.com

さて表題の件ですが、テストを書いているとアサーションをどこで持つべきかという課題にぶち当たります。

シンプルな例

AppiumによるiOS/AndroidアプリへのE2Eテストを例に挙げます。 アプリ内の特定のスクリーンに対してUI要素が期待通りにレンダリングされているかテストしたいとします。 簡潔ですが以下のようなコードになると思います。(pytestを想定)

PageObject

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

    def title(self):
        return self.driver.find_element_by_accessibility_id('title')

テストコード

def test_title_is_displayed(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN title is displayed as expected
    assert some_page.title.is_displayed()

SomePageというスクリーンのタイトルが期待通り表示されているかのテストになります。 上記のコードではテストコードにアサーションを持たせていますのでPageObjectはあくまで具体的なUI要素へのアクセス方法を隠蔽する役目に徹しています。

ここで冒頭に紹介したマーティン・ファウラーのブログから彼の意見を参照してみます。

I favor having no assertions in page objects. I think you can avoid duplication by providing assertion libraries for common assertions - which can also make it easier to provide good diagnostics. [3]

Page objects are commonly used for testing, but should not make assertions themselves. Their responsibility is to provide access to the state of the underlying page. It's up to test clients to carry out the assertion logic.

つまり彼はPageObjectにアサーション持たないことを好むそうです。 PageObjectの役目はそのページの状態へのアクセスを提供することであるからだと述べています。 Single Responsibility Principle(単一責任の原則)にも通ずる話かと思います。

en.wikipedia.org

you can avoid duplication by providing assertion libraries for common assertions - which can also make it easier to provide good diagnostics.

この言及に関しては、ちょっと何言ってるか分からないです。

少し複雑な例

では先程のサンプルコードに戻ります。 今回はタイトルだけではなくページの主要なUI要素全てについてテストするとします。

PageObject

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

    def title(self):
        return self.driver.find_element_by_accessibility_id('title')

    def description(self):
        return self.driver.find_element_by_accessibility_id('description')

    def image(self):
        return self.driver.find_element_by_accessibility_id('image')

    def button(self):
        return self.driver.find_element_by_accessibility_id('button')

テストコード

def test_a_bit_complicated(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN title is displayed as expected
    assert some_page.title().is_displayed()
    # THEN description is displayed as expected
    assert some_page.description().is_displayed()
    # THEN image is displayed as expected
    assert some_page.image().is_displayed()
    # THEN button is displayed as expected
    assert some_page.button().is_displayed()

いかがでしょうか?テストコードにアサーションが増えてきて少し煩雑なテストになってきたような気がします。まだこれくらいならマシかもしれませんが、もしテスト対象のUI要素が10個や20個になったらどうでしょう。もはやテストコードがアサーションだらけになってよろしくない気がします。

では試しにPageObject自体にアサーションを持たせてみましょう。

PageObject

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

    def assert_appearance(self):
        assert self.driver.find_element_by_accessibility_id('title').is_displayed()
        assert self.driver.find_element_by_accessibility_id('description').is_displayed()
        assert self.driver.find_element_by_accessibility_id('image').is_displayed()
        assert self.driver.find_element_by_accessibility_id('button').is_displayed()

テストコード

def test_a_bit_complicated(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN all UI components are displayed as expected
    some_page.assert_appearance()

めちゃくちゃスッキリしました。たとえUI変更があってもPageObjectの変更だけで済むこともこのメリットの1つかなと思います。

ただ、もしPageObjectにアサーションを持たせるなら、テストコードにはアサーションを一切持たせたくない気がします。 なぜならそれぞれの役目の境界が曖昧になるからです。 コードを書く人もどっちにアサーション書けばいいか迷ってしまうでしょう。 では果たして「テストコードに一切アサーションを持たない」ことは可能なのでしょうか?

より複雑な例

では次に画面上のボタンをクリックしたらボタンがdisabledになるテストも追加するとします。 アサーションはPageObjectにのみ持つという方針の場合、このようなコードになるかと思います。

テストコード

def test_more_complicated(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN all UI components are displayed as expected
    some_page.assert_appearance()

    # WHEN click on button
    some_page.button().click()

    # THEN button becomes disabled
    some_page.assert_button_is_disabled()

PageObject

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

    def assert_appearance(self):
        assert self.driver.find_element_by_accessibility_id('title').is_displayed()
        assert self.driver.find_element_by_accessibility_id('description').is_displayed()
        assert self.driver.find_element_by_accessibility_id('image').is_displayed()
        assert self.button().is_displayed()

    # Accessor for the button
    def button(self):
        return self.driver.find_element_by_accessibility_id('button')

    # Assertion for verifying disabled state
    def assert_button_is_disabled(self):
        assert not self.button().is_enabled()

まあこれはこれでいいのですが、assert_button_is_disabled()が冗長な気がします。同様の実装が増えていったらPageObjectが肥大化してしまって良くないと思います。

また、せっかくbuttonのアクセサが公開されているのだから、以下のようにテストコード内で直接buttonのstateをチェックした方がシンプルではないでしょうか。

テストコード

def test_more_complicated(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN all UI components are displayed as expected
    some_page.assert_appearance()

    # WHEN click on button
    some_page.button().click()

    # THEN button becomes disabled
    # some_page.assert_button_is_disabled()  # Before
    assert not some_page.button().is_enabled()  # After

ただこうなると先程懸念していた「テストコードとPageObjectが共にアサーションを持つ曖昧な設計」になってしまいます。

ではではassert_appearance()の方をなんとかしてみることにします。こんなのはどうでしょうか?

PageObject

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

    def has_correct_appearance(self):
        return self.driver.find_element_by_accessibility_id('title').is_displayed()
            and self.driver.find_element_by_accessibility_id('description').is_displayed()
            and self.driver.find_element_by_accessibility_id('image').is_displayed()
            and self.button().is_displayed()

    # Accessor for the button
    def button(self):
        return self.driver.find_element_by_accessibility_id('button')

テストコード

def test_more_complicated(self):
    # WHEN navigate to SomePage
    some_page = SomePage(self.driver)

    # THEN SomePage has correct appearance
    assert some_page.has_correct_appearance()

    # WHEN click on button
    some_page.button().click()

    # THEN button becomes disabled
    assert not some_page.button().is_enabled()

一応これでテストコードだけにアサーションを持つというデザインに出来ました。 またたとえUI変更があったとしてもPageObjectのhas_correct_appearance()の実装を更新するだけで済む点もグッドです。 ただ完全にはしっくりこない気持ち。

結論

色々と検討してみましたが、完全にしっくりくる実装方法が見つかりません。誰か教えてください。