Skip to content

Commit 3ca9472

Browse files
Updated part 4 instructions
1 parent 89f8256 commit 3ca9472

File tree

2 files changed

+95
-100
lines changed

2 files changed

+95
-100
lines changed

tutorial/4-page-objects.md

Lines changed: 95 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -47,45 +47,40 @@ We will use much of our original code.
4747

4848
A page object class typically has three main parts:
4949

50-
1. Selectors and any other data stored as variables
51-
2. Dependency injection of the browser automator through a constructor
50+
1. Dependency injection of the browser automator through a constructor
51+
2. Locators and any other data stored as variables
5252
3. Interaction methods that use the browser automator and the selectors
5353

5454
Let's add these one at a time.
55-
Inside `pages/search.py`, add a class definition for the page object:
55+
Inside `pages/search.py`, import Playwright's `Page` class:
5656

5757
```python
58-
class DuckDuckGoSearchPage:
58+
from playwright.sync_api import Page
5959
```
6060

61-
Inside this class, add the selectors we used in our test for the search input and search button:
61+
Add a class definition for the page object:
6262

6363
```python
64-
SEARCH_BUTTON = '#search_button_homepage'
65-
SEARCH_INPUT = '#search_form_input_homepage'
64+
class DuckDuckGoSearchPage:
6665
```
6766

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:
67+
Inside this class, add the DuckDuckGo URL:
7368

7469
```python
7570
URL = 'https://www.duckduckgo.com'
7671
```
7772

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 tutorial.)*
73+
> *Warning:*
74+
> Base URLs should typically be passed into automation code as an input, not hard-coded in a page object.
75+
> We are doing this here as a matter of simplicity for this tutorial.
8176
8277
Next, let's handle dependency injection for the browser automator.
8378
Since each test will have its own Playwright page, we should inject that page.
8479
(If we were using Selenium WebDriver, then we would inject the WebDriver instance.)
8580
Add the following initializer method to the class:
8681

8782
```python
88-
def __init__(self, page):
83+
def __init__(self, page: Page) -> None:
8984
self.page = page
9085
```
9186

@@ -94,12 +89,22 @@ The `__init__` method is essentially a constructor for Python classes
9489
It has one argument named `page` for the Playwright page,
9590
which it stores as an instance variable (via `self`).
9691

97-
With the page injected, we can now use it to make interactions.
92+
Let's also add locators for search page elements to the constructor.
93+
Our test needs locators for the search button and the search input:
94+
95+
```python
96+
self.search_button = page.locator('#search_button_homepage')
97+
self.search_input = page.locator('#search_form_input_homepage')
98+
```
99+
100+
These locators are created once and can be used anywhere.
101+
We can use them to make interactions.
102+
98103
One interaction our test performs is loading the DuckDuckGo search page.
99104
Here's a method to do that:
100105

101106
```python
102-
def load(self):
107+
def load(self) -> None:
103108
self.page.goto(self.URL)
104109
```
105110

@@ -109,61 +114,58 @@ The other interaction our test performs is searching for a phrase.
109114
Here's a method to do that:
110115

111116
```python
112-
def search(self, phrase):
113-
self.page.fill(self.SEARCH_INPUT, phrase)
114-
self.page.click(self.SEARCH_BUTTON)
117+
def search(self, phrase: str) -> None:
118+
self.search_input.fill(phrase)
119+
self.search_button.click()
115120
```
116121

117-
This `search` method uses the injected page and the selector variables.
122+
This `search` method uses the page objects to perform the search.
118123
It also takes in the search phrase as an argument so that it can handle any phrase.
119124

120125
The completed search page object class should look like this:
121126

122127
```python
123-
class DuckDuckGoSearchPage:
128+
from playwright.sync_api import Page
124129

125-
SEARCH_BUTTON = '#search_button_homepage'
126-
SEARCH_INPUT = '#search_form_input_homepage'
130+
class DuckDuckGoSearchPage:
127131

128132
URL = 'https://www.duckduckgo.com'
129133

130-
def __init__(self, page):
134+
def __init__(self, page: Page) -> None:
131135
self.page = page
136+
self.search_button = page.locator('#search_button_homepage')
137+
self.search_input = page.locator('#search_form_input_homepage')
132138

133-
def load(self):
139+
def load(self) -> None:
134140
self.page.goto(self.URL)
135141

136-
def search(self, phrase):
137-
self.page.fill(self.SEARCH_INPUT, phrase)
138-
self.page.click(self.SEARCH_BUTTON)
142+
def search(self, phrase: str) -> None:
143+
self.search_input.fill(phrase)
144+
self.search_button.click()
139145
```
140146

141-
This diagram shows how each section of this class fits the standard sections of a page object class:
142-
143-
![Page object structure](images/page-object-structure.png)
144-
145147
We can now refactor the original test case to use this new page object!
146148
Replace this old code:
147149

148150
```python
149-
def test_basic_duckduckgo_search(page):
150-
151+
def test_basic_duckduckgo_search(page: Page) -> None:
152+
151153
# Given the DuckDuckGo home page is displayed
152154
page.goto('https://www.duckduckgo.com')
153155

154156
# When the user searches for a phrase
155-
page.fill('#search_form_input_homepage', 'panda')
156-
page.click('#search_button_homepage')
157+
page.locator('#search_form_input_homepage').fill('panda')
158+
page.locator('#search_button_homepage').click()
157159
```
158160

159161
With this new code:
160162

161163
```python
162164
from pages.search import DuckDuckGoSearchPage
163165

164-
def test_basic_duckduckgo_search(page):
166+
def test_basic_duckduckgo_search(page: Page) -> None:
165167
search_page = DuckDuckGoSearchPage(page)
166-
168+
167169
# Given the DuckDuckGo home page is displayed
168170
search_page.load()
169171

@@ -190,37 +192,34 @@ It will follow the same structure.
190192
The main difference is that each interaction methods in the result page class will return a value
191193
because test assertions will check page values.
192194

193-
Start by adding the class definition to `pages/result.py`:
195+
Start by adding the following imports for type checking to `pages/result.py`:
194196

195197
```python
196-
class DuckDuckGoResultPage:
198+
from playwright.sync_api import Page
199+
from typing import List
197200
```
198201

199-
Add the selectors we used in the test case:
202+
Add the class definition:
200203

201204
```python
202-
RESULT_LINKS = '.result__title a.result__a'
203-
SEARCH_INPUT = '#search_form_input'
205+
class DuckDuckGoResultPage:
204206
```
205207

206-
Add dependency injection:
208+
Add dependency injection with locators:
207209

208210
```python
209-
def __init__(self, page):
211+
def __init__(self, page: Page) -> None:
210212
self.page = page
213+
self.result_links = page.locator('.result__title a.result__a')
214+
self.search_input = page.locator('#search_form_input')
211215
```
212216

213-
Now, let's add interaction methods for all the things assertions must check.
214-
The first assertion checked the input value of the search input.
215-
Let's add a method to get and return that input value:
216-
217-
```python
218-
def search_input_value(self):
219-
return self.page.input_value(self.SEARCH_INPUT)
220-
```
221-
222-
The second assertion was the most complex.
223-
It checked if at least one result link title contained the search phrase.
217+
Now, let's add interaction methods.
218+
Since the verifications for the search input and title are simple,
219+
we don't need new methods for those.
220+
The test case function can call the `search_input` locator and the `page` object directly for those.
221+
However, the verification for search result links has some complex code
222+
that should be handled within the page object.
224223
We can break this down into two methods:
225224

226225
1. A method to get all result link titles as a list.
@@ -229,20 +228,21 @@ We can break this down into two methods:
229228
Add the following methods to the class:
230229

231230
```python
232-
def result_link_titles(self):
233-
self.page.locator(f'{self.RESULT_LINKS} >> nth=4').wait_for()
234-
titles = self.page.locator(self.RESULT_LINKS).all_text_contents()
235-
return titles
231+
def result_link_titles(self) -> List[str]:
232+
self.result_links.nth(4).wait_for()
233+
return self.result_links.all_text_contents()
236234

237-
def result_link_titles_contain_phrase(self, phrase, minimum=1):
235+
def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool:
238236
titles = self.result_link_titles()
239237
matches = [t for t in titles if phrase.lower() in t.lower()]
240238
return len(matches) >= minimum
241239
```
242240

243-
In the first method, the `RESULT_LINKS` selector is used twice.
244-
The first time it is used, it is concatenated with the N-th element selector using an
245-
[f-string](https://realpython.com/python-f-strings/).
241+
In the first method, the `result_links` locator is used twice.
242+
The first time it is called,
243+
it is concatenated with the N-th element fetcher to wait for at least 5 elements to appear.
244+
The second time it is called,
245+
it gets all the text contents for the elements it finds.
246246

247247
The second method takes in a search phases and a minimum limit for matches.
248248
It calls the first method to get the list of titles,
@@ -252,41 +252,28 @@ Notice that this method does **not** perform an asssertion.
252252
Assertions should *not* be done in page objects.
253253
They should only be done in test cases.
254254

255-
The third assertion checked the page title.
256-
Let's add one final method to the result page class to get the title:
257-
258-
```python
259-
def title(self):
260-
return self.page.title()
261-
```
262-
263255
The full code for `pages/result.py` should look like this
264256
(after rearranging methods alphabetically):
265257

266258
```python
259+
from playwright.sync_api import Page
260+
from typing import List
261+
267262
class DuckDuckGoResultPage:
268263

269-
RESULT_LINKS = '.result__title a.result__a'
270-
SEARCH_INPUT = '#search_form_input'
271-
272-
def __init__(self, page):
264+
def __init__(self, page: Page) -> None:
273265
self.page = page
266+
self.result_links = page.locator('.result__title a.result__a')
267+
self.search_input = page.locator('#search_form_input')
274268

275-
def result_link_titles(self):
276-
self.page.locator(f'{self.RESULT_LINKS} >> nth=4').wait_for()
277-
titles = self.page.locator(self.RESULT_LINKS).all_text_contents()
278-
return titles
269+
def result_link_titles(self) -> List[str]:
270+
self.result_links.nth(4).wait_for()
271+
return self.result_links.all_text_contents()
279272

280-
def result_link_titles_contain_phrase(self, phrase, minimum=1):
273+
def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool:
281274
titles = self.result_link_titles()
282275
matches = [t for t in titles if phrase.lower() in t.lower()]
283276
return len(matches) >= minimum
284-
285-
def search_input_value(self):
286-
return self.page.input_value(self.SEARCH_INPUT)
287-
288-
def title(self):
289-
return self.page.title()
290277
```
291278

292279
After rewriting the original test case to use `DuckDuckGoResultPage`,
@@ -295,8 +282,9 @@ the code in `tests/test_search.py` should look like this:
295282
```python
296283
from pages.result import DuckDuckGoResultPage
297284
from pages.search import DuckDuckGoSearchPage
285+
from playwright.sync_api import expect, Page
298286

299-
def test_basic_duckduckgo_search(page):
287+
def test_basic_duckduckgo_search(page: Page) -> None:
300288
search_page = DuckDuckGoSearchPage(page)
301289
result_page = DuckDuckGoResultPage(page)
302290

@@ -307,13 +295,13 @@ def test_basic_duckduckgo_search(page):
307295
search_page.search('panda')
308296

309297
# Then the search result query is the phrase
310-
assert 'panda' == result_page.search_input_value()
298+
expect(result_page.search_input).to_have_value('panda')
311299

312300
# And the search result links pertain to the phrase
313301
assert result_page.result_link_titles_contain_phrase('panda')
314302

315303
# And the search result title contains the phrase
316-
assert 'panda' in result_page.title()
304+
expect(page).to_have_title('panda at DuckDuckGo')
317305
```
318306

319307
These calls look less "code-y" than the raw Playwright calls.
@@ -352,13 +340,14 @@ import pytest
352340

353341
from pages.result import DuckDuckGoResultPage
354342
from pages.search import DuckDuckGoSearchPage
343+
from playwright.sync_api import Page
355344

356345
@pytest.fixture
357-
def result_page(page):
346+
def result_page(page: Page) -> DuckDuckGoResultPage:
358347
return DuckDuckGoResultPage(page)
359348

360349
@pytest.fixture
361-
def search_page(page):
350+
def search_page(page: Page) -> DuckDuckGoSearchPage:
362351
return DuckDuckGoSearchPage(page)
363352
```
364353

@@ -374,33 +363,39 @@ You can learn more about fixtures from the
374363
To use these new fixtures, rewrite `tests/test_search.py` like this:
375364

376365
```python
377-
def test_basic_duckduckgo_search(search_page, result_page):
366+
from pages.result import DuckDuckGoResultPage
367+
from pages.search import DuckDuckGoSearchPage
368+
from playwright.sync_api import expect, Page
378369

370+
def test_basic_duckduckgo_search(
371+
page: Page,
372+
search_page: DuckDuckGoSearchPage,
373+
result_page: DuckDuckGoResultPage) -> None:
374+
379375
# Given the DuckDuckGo home page is displayed
380376
search_page.load()
381377

382378
# When the user searches for a phrase
383379
search_page.search('panda')
384380

385381
# Then the search result query is the phrase
386-
assert 'panda' == result_page.search_input_value()
382+
expect(result_page.search_input).to_have_value('panda')
387383

388384
# And the search result links pertain to the phrase
389385
assert result_page.result_link_titles_contain_phrase('panda')
390386

391387
# And the search result title contains the phrase
392-
assert 'panda' in result_page.title()
388+
expect(page).to_have_title('panda at DuckDuckGo')
393389
```
394390

395391
Notice a few things:
396392

397-
* This test module no longer needs to import the page object classes from the `pages` module.
398393
* The `search_page` and `result_page` fixtures are declared as arguments for the test function.
399394
* The test function no longer explicitly constructs page objects.
400-
* The Playwright `page` fixture is no longer declared because it is no longer called directly by the test function.
395+
* Each test step is only one line long.
401396

402397
If you use page objects, then **all interactions should be performed using page objects**.
403-
It is not recommended to mix raw Playwright calls with page object calls.
398+
It is not recommended to mix raw Playwright calls (except `expect` assertions) with page object calls.
404399
That becomes confusing, and it encourages poor practices like dirty hacks and copypasta.
405400
It also causes a test automation project to lose strength from a lack of conformity in design.
406401

-287 KB
Binary file not shown.

0 commit comments

Comments
 (0)