E2EテストのアサーションをテストコードとPageObjectどちらに持つべきかという話
E2EテストのデザインパターンとしてPageObjectというものがあります。 今回はPageObject自体の説明は省きますが、簡潔に述べるとWebページなどの詳細(idとかclassとかDOM階層とかとか)を隠して利用側(テストコード)が利用しやすいインターフェースを提供する役目という認識です私は。
より詳しい内容はマーティン・ファウラーのブログをご参照ください。
さて表題の件ですが、テストを書いているとアサーションをどこで持つべきかという課題にぶち当たります。
シンプルな例
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(単一責任の原則)にも通ずる話かと思います。
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()
の実装を更新するだけで済む点もグッドです。
ただ完全にはしっくりこない気持ち。
結論
色々と検討してみましたが、完全にしっくりくる実装方法が見つかりません。誰か教えてください。