|
| 1 | +# Refactoring using page objects |
| 2 | + |
| 3 | +As we saw in the previous part, Playwright calls are wonderfully concise. |
| 4 | +We may be tempted to use raw Playwright calls in all our tests. |
| 5 | +However, raw calls quickly lead to code duplication. |
| 6 | + |
| 7 | +In this part, we will refactor our DuckDuckGo search test using the |
| 8 | +[Page Object Model](https://www.selenium.dev/documentation/guidelines/page_object_models/) (POM). |
| 9 | +Page objects, though imperfect, provide decent structure and helpful reusability. |
| 10 | +They are superior to raw Playwright calls when automating multiple tests instead of only one script. |
| 11 | + |
| 12 | + |
| 13 | +## The search page |
| 14 | + |
| 15 | +Our search tests interacts with two pages: |
| 16 | + |
| 17 | +1. The DuckDuckGo search page |
| 18 | +2. The DuckDuckGo result page |
| 19 | + |
| 20 | +Each page should be modeled by its own class. |
| 21 | +Page object classes should be located in a package outside of the `tests` directory so that they can be imported by tests. |
| 22 | + |
| 23 | +Create a new directory named `pages`, and inside it, create blank files with the following names: |
| 24 | + |
| 25 | +* `__init__.py` |
| 26 | +* `search.py` |
| 27 | +* `result.py` |
| 28 | + |
| 29 | +Your project's directory layout should look like this: |
| 30 | + |
| 31 | +``` |
| 32 | +tau-playwright-workshop |
| 33 | +├── pages |
| 34 | +│ ├── __init__.py |
| 35 | +│ ├── search.py |
| 36 | +│ └── result.py |
| 37 | +└── tests |
| 38 | + └── test_search.py |
| 39 | +``` |
| 40 | + |
| 41 | +The `__init__.py` file turns the `pages` directory into a Python package so that other Python modules can import it. |
| 42 | +It will stay permanently empty. |
| 43 | +The `search.py` and `result.py` modules will contain the search and result page object classes respectively. |
| 44 | + |
| 45 | +Let's implement the search page first. |
| 46 | +We will use much of our original code. |
| 47 | + |
| 48 | +A page object class typically has three main parts: |
| 49 | + |
| 50 | +1. Selectors and any other data stored as variables |
| 51 | +2. Dependency injection of the browser automator through a constructor |
| 52 | +3. Interaction methods that use the browser automator and the selectors |
| 53 | + |
| 54 | +Let's add these one at a time. |
| 55 | +Inside `pages/search.py`, add a class definition for the page object: |
| 56 | + |
| 57 | +```python |
| 58 | +class DuckDuckGoSearchPage: |
| 59 | +``` |
| 60 | + |
| 61 | +Inside this class, add the selectors we used in our test for the search input and search button: |
| 62 | + |
| 63 | +```python |
| 64 | + SEARCH_BUTTON = '#search_button_homepage' |
| 65 | + SEARCH_INPUT = '#search_form_input_homepage' |
| 66 | +``` |
| 67 | + |
| 68 | +These will be class variables, not instance variables. |
| 69 | +They are also plain-old strings. |
| 70 | +We could use these selectors for many different actions. |
| 71 | + |
| 72 | +Let's also add the DuckDuckGo URL: |
| 73 | + |
| 74 | +```python |
| 75 | + URL = 'https://www.duckduckgo.com' |
| 76 | +``` |
| 77 | + |
| 78 | +*(Warning: |
| 79 | +Base URLs should typically be passed into automation code as an input, not hard-coded in a page object. |
| 80 | +We are doing this here as a matter of simplicity for this workshop.)* |
| 81 | + |
| 82 | +Next, let's handle dependency injection for the browser automator. |
| 83 | +Since each test will have its own Playwright page, we should inject that page. |
| 84 | +(If we were using Selenium WebDriver, then we would inject the WebDriver instance.) |
| 85 | +Add the following initializer method to the class: |
| 86 | + |
| 87 | +```python |
| 88 | + def __init__(self, page): |
| 89 | + self.page = page |
| 90 | +``` |
| 91 | + |
| 92 | +The `__init__` method is essentially a constructor for Python classes |
| 93 | +(but with a bit of nuance that doesn't matter for this workshop). |
| 94 | +It has one argument named `page` for the Playwright page, |
| 95 | +which it stores as an instance variable (via `self`). |
| 96 | + |
| 97 | +With the page injected, we can now use it to make interactions. |
| 98 | +One interaction our test performs is loading the DuckDuckGo search page. |
| 99 | +Here's a method to do that: |
| 100 | + |
| 101 | +```python |
| 102 | + def load(self): |
| 103 | + self.page.goto(self.URL) |
| 104 | +``` |
| 105 | + |
| 106 | +It uses the injected page as well as the `URL` variable. |
| 107 | + |
| 108 | +The other interaction our test performs is searching for a phrase. |
| 109 | +Here's a method to do that: |
| 110 | + |
| 111 | +```python |
| 112 | + def search(self, phrase): |
| 113 | + self.page.fill(self.SEARCH_INPUT, phrase) |
| 114 | + self.page.click(self.SEARCH_BUTTON) |
| 115 | +``` |
| 116 | + |
| 117 | +This `search` method uses the injected page and the selector variables. |
| 118 | +It also takes in the search phrase as an argument so that it can handle any phrase. |
| 119 | + |
| 120 | +The completed search page object class should look like this: |
| 121 | + |
| 122 | +```python |
| 123 | +class DuckDuckGoSearchPage: |
| 124 | + |
| 125 | + SEARCH_BUTTON = '#search_button_homepage' |
| 126 | + SEARCH_INPUT = '#search_form_input_homepage' |
| 127 | + |
| 128 | + URL = 'https://www.duckduckgo.com' |
| 129 | + |
| 130 | + def __init__(self, page): |
| 131 | + self.page = page |
| 132 | + |
| 133 | + def load(self): |
| 134 | + self.page.goto(self.URL) |
| 135 | + |
| 136 | + def search(self, phrase): |
| 137 | + self.page.fill(self.SEARCH_INPUT, phrase) |
| 138 | + self.page.click(self.SEARCH_BUTTON) |
| 139 | +``` |
| 140 | + |
| 141 | +This diagram shows how each section of this class fits the standard sections of a page object class: |
| 142 | + |
| 143 | + |
| 144 | + |
| 145 | +We can now refactor the original test case to use this new page object! |
| 146 | +Replace this old code: |
| 147 | + |
| 148 | +```python |
| 149 | +def test_basic_duckduckgo_search(page): |
| 150 | + |
| 151 | + # Given the DuckDuckGo home page is displayed |
| 152 | + page.goto('https://www.duckduckgo.com') |
| 153 | + |
| 154 | + # When the user searches for a phrase |
| 155 | + page.fill('#search_form_input_homepage', 'panda') |
| 156 | + page.click('#search_button_homepage') |
| 157 | +``` |
| 158 | + |
| 159 | +With this new code: |
| 160 | + |
| 161 | +```python |
| 162 | +from pages.search import DuckDuckGoSearchPage |
| 163 | + |
| 164 | +def test_basic_duckduckgo_search(page): |
| 165 | + search_page = DuckDuckGoSearchPage(page) |
| 166 | + |
| 167 | + # Given the DuckDuckGo home page is displayed |
| 168 | + search_page.load() |
| 169 | + |
| 170 | + # When the user searches for a phrase |
| 171 | + search_page.search('panda') |
| 172 | +``` |
| 173 | + |
| 174 | +The new code must import `DuckDuckGoSearchPage` from the `pages.search` module. |
| 175 | +The test then constructs a `DuckDuckGoSearchPage` object and uses it to perform interactions. |
| 176 | +Notice that the test case no longer has hard-coded selectors or URLs. |
| 177 | +The code is also more self-documenting. |
| 178 | + |
| 179 | +Rerun the test (`python3 -m pytest tests --headed --slowmo 1000`). |
| 180 | +The test should pass. |
| 181 | +Nothing has functionally changed for the test: |
| 182 | +it still performs the same operations. |
| 183 | +Now, it just uses a page object for the search page instead of raw calls. |
| 184 | + |
| 185 | + |
| 186 | +## The result page |
| 187 | + |
| 188 | + |
| 189 | +## Page object fixtures |
0 commit comments