diff --git a/README.md b/README.md index d6e0d82..2a18a8c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ # Tutorial: Web Testing with Playwright in Python +*Warning: Many parts of this tutorial have fallen out of date. I hope to update it sometime in the future.* + ## Abstract @@ -121,4 +123,4 @@ The branch names are: | Part 4 | 4-page-objects | | Part 5 | 5-playwright-tricks | | Part 6 | 6-api-testing | -| Complete | main | \ No newline at end of file +| Complete | main | diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pages/result.py b/pages/result.py new file mode 100644 index 0000000..53c591c --- /dev/null +++ b/pages/result.py @@ -0,0 +1,24 @@ +""" +This module contains DuckDuckGoResultPage, +the page object for the DuckDuckGo result page. +""" + +from playwright.sync_api import Page +from typing import List + + +class DuckDuckGoResultPage: + + def __init__(self, page: Page) -> None: + self.page = page + self.result_links = page.locator('a[data-testid="result-title-a"]') + self.search_input = page.locator('#search_form_input') + + def result_link_titles(self) -> List[str]: + self.result_links.nth(4).wait_for() + return self.result_links.all_text_contents() + + def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool: + titles = self.result_link_titles() + matches = [t for t in titles if phrase.lower() in t.lower()] + return len(matches) >= minimum diff --git a/pages/search.py b/pages/search.py new file mode 100644 index 0000000..92d7103 --- /dev/null +++ b/pages/search.py @@ -0,0 +1,23 @@ +""" +This module contains DuckDuckGoSearchPage, +the page object for the DuckDuckGo search page. +""" + +from playwright.sync_api import Page + + +class DuckDuckGoSearchPage: + + URL = 'https://www.duckduckgo.com' + + def __init__(self, page: Page) -> None: + self.page = page + self.search_button = page.locator('#search_button_homepage') + self.search_input = page.locator('#search_form_input_homepage') + + def load(self) -> None: + self.page.goto(self.URL) + + def search(self, phrase: str) -> None: + self.search_input.fill(phrase) + self.search_button.click() diff --git a/requirements.txt b/requirements.txt index c0dec06..1852b16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ attrs==21.2.0 certifi==2021.10.8 charset-normalizer==2.0.8 +execnet==1.9.0 greenlet==1.1.2 idna==3.3 iniconfig==1.1.1 @@ -12,7 +13,9 @@ pyee==8.1.0 pyparsing==3.0.6 pytest==7.0.1 pytest-base-url==1.4.2 +pytest-forked==1.4.0 pytest-playwright==0.2.3 +pytest-xdist==2.5.0 python-slugify==5.0.2 requests==2.26.0 text-unidecode==1.3 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fb5ef24 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,118 @@ +""" +This module contains shared fixtures. +""" + +# ------------------------------------------------------------ +# Imports +# ------------------------------------------------------------ + +import os +import pytest + +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage +from playwright.sync_api import Playwright, APIRequestContext, Page, expect +from typing import Generator + + +# ------------------------------------------------------------ +# DuckDuckGo search fixtures +# ------------------------------------------------------------ + +@pytest.fixture +def result_page(page: Page) -> DuckDuckGoResultPage: + return DuckDuckGoResultPage(page) + + +@pytest.fixture +def search_page(page: Page) -> DuckDuckGoSearchPage: + return DuckDuckGoSearchPage(page) + + +# ------------------------------------------------------------ +# GitHub project fixtures +# ------------------------------------------------------------ + +# Environment variables + +def _get_env_var(varname: str) -> str: + value = os.getenv(varname) + assert value, f'{varname} is not set' + return value + + +@pytest.fixture(scope='session') +def gh_username() -> str: + return _get_env_var('GITHUB_USERNAME') + + +@pytest.fixture(scope='session') +def gh_password() -> str: + return _get_env_var('GITHUB_PASSWORD') + + +@pytest.fixture(scope='session') +def gh_access_token() -> str: + return _get_env_var('GITHUB_ACCESS_TOKEN') + + +@pytest.fixture(scope='session') +def gh_project_name() -> str: + return _get_env_var('GITHUB_PROJECT_NAME') + + +# Request context + +@pytest.fixture(scope='session') +def gh_context( + playwright: Playwright, + gh_access_token: str) -> Generator[APIRequestContext, None, None]: + + headers = { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"token {gh_access_token}"} + + request_context = playwright.request.new_context( + base_url="https://api.github.com", + extra_http_headers=headers) + + yield request_context + request_context.dispose() + + +# GitHub project requests + +@pytest.fixture(scope='session') +def gh_project( + gh_context: APIRequestContext, + gh_username: str, + gh_project_name: str) -> dict: + + resource = f'/users/{gh_username}/projects' + response = gh_context.get(resource) + expect(response).to_be_ok() + + name_match = lambda x: x['name'] == gh_project_name + filtered = filter(name_match, response.json()) + project = list(filtered)[0] + assert project + + return project + + +@pytest.fixture() +def project_columns( + gh_context: APIRequestContext, + gh_project: dict) -> list[dict]: + + response = gh_context.get(gh_project['columns_url']) + expect(response).to_be_ok() + + columns = response.json() + assert len(columns) >= 2 + return columns + + +@pytest.fixture() +def project_column_ids(project_columns: list[dict]) -> list[str]: + return list(map(lambda x: x['id'], project_columns)) diff --git a/tests/test_github_project.py b/tests/test_github_project.py new file mode 100644 index 0000000..7ce3bc5 --- /dev/null +++ b/tests/test_github_project.py @@ -0,0 +1,88 @@ +""" +These tests cover API interactions for GitHub projects. +""" + +# ------------------------------------------------------------ +# Imports +# ------------------------------------------------------------ + +import time + +from playwright.sync_api import APIRequestContext, Page, expect + + +# ------------------------------------------------------------ +# A pure API test +# ------------------------------------------------------------ + +def test_create_project_card( + gh_context: APIRequestContext, + project_column_ids: list[str]) -> None: + + # Prep test data + now = time.time() + note = f'A new task at {now}' + + # Create a new card + c_response = gh_context.post( + f'/projects/columns/{project_column_ids[0]}/cards', + data={'note': note}) + expect(c_response).to_be_ok() + assert c_response.json()['note'] == note + + # Retrieve the newly created card + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json() == c_response.json() + + +# ------------------------------------------------------------ +# A hybrid UI/API test +# ------------------------------------------------------------ + +def test_move_project_card( + gh_context: APIRequestContext, + gh_project: dict, + project_column_ids: list[str], + page: Page, + gh_username: str, + gh_password: str) -> None: + + # Prep test data + source_col = project_column_ids[0] + dest_col = project_column_ids[1] + now = time.time() + note = f'Move this card at {now}' + + # Create a new card via API + c_response = gh_context.post( + f'/projects/columns/{source_col}/cards', + data={'note': note}) + expect(c_response).to_be_ok() + + # Log in via UI + page.goto(f'https://github.com/login') + page.locator('id=login_field').fill(gh_username) + page.locator('id=password').fill(gh_password) + page.locator('input[name="commit"]').click() + + # Load the project page + page.goto(f'https://github.com/users/{gh_username}/projects/{gh_project["number"]}') + + # Verify the card appears in the first column + card_xpath = f'//div[@id="column-cards-{source_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() + + # Move a card to the second column via web UI + page.drag_and_drop(f'text="{note}"', f'id=column-cards-{dest_col}') + + # Verify the card is in the second column via UI + card_xpath = f'//div[@id="column-cards-{dest_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() + + # Verify the backend is updated via API + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json()['column_url'].endswith(str(dest_col)) diff --git a/tests/test_search.py b/tests/test_search.py index 5f38e26..a9c762f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -2,26 +2,45 @@ These tests cover DuckDuckGo searches. """ +import pytest + +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage from playwright.sync_api import expect, Page -def test_basic_duckduckgo_search(page: Page) -> None: +ANIMALS = [ + 'panda', + 'python', + 'polar bear', + 'parrot', + 'porcupine', + 'parakeet', + 'pangolin', + 'panther', + 'platypus', + 'peacock' +] + + +@pytest.mark.parametrize('phrase', ANIMALS) +def test_basic_duckduckgo_search( + phrase: str, + page: Page, + search_page: DuckDuckGoSearchPage, + result_page: DuckDuckGoResultPage) -> None: # Given the DuckDuckGo home page is displayed - page.goto('https://www.duckduckgo.com') + search_page.load() # When the user searches for a phrase - page.locator('#search_form_input_homepage').fill('panda') - page.locator('#search_button_homepage').click() + search_page.search(phrase) # Then the search result query is the phrase - expect(page.locator('#search_form_input')).to_have_value('panda') + expect(result_page.search_input).to_have_value(phrase) # And the search result links pertain to the phrase - page.locator('a[data-testid="result-title-a"]').nth(4).wait_for() - titles = page.locator('a[data-testid="result-title-a"]').all_text_contents() - matches = [t for t in titles if 'panda' in t.lower()] - assert len(matches) > 0 + assert result_page.result_link_titles_contain_phrase(phrase) # And the search result title contains the phrase - expect(page).to_have_title('panda at DuckDuckGo') + expect(page).to_have_title(f'{phrase} at DuckDuckGo') diff --git a/tutorial/4-page-objects.md b/tutorial/4-page-objects.md new file mode 100644 index 0000000..5e99af4 --- /dev/null +++ b/tutorial/4-page-objects.md @@ -0,0 +1,404 @@ +# Part 4: Refactoring using page objects + +As we saw in the previous part, Playwright calls are wonderfully concise. +We may be tempted to use raw Playwright calls in all our tests. +However, raw calls quickly lead to code duplication. + +In this part, we will refactor our DuckDuckGo search test using the +[Page Object Model](https://www.selenium.dev/documentation/guidelines/page_object_models/) (POM). +Page objects, though imperfect, provide decent structure and helpful reusability. +They are superior to raw Playwright calls when automating multiple tests instead of only one script. + + +## The search page + +Our search tests interacts with two pages: + +1. The DuckDuckGo search page +2. The DuckDuckGo result page + +Each page should be modeled by its own class. +Page object classes should be located in a package outside of the `tests` directory so that they can be imported by tests. + +Create a new directory named `pages`, and inside it, create blank files with the following names: + +* `__init__.py` +* `search.py` +* `result.py` + +Your project's directory layout should look like this: + +``` +playwright-python-tutorial +├── pages +│ ├── __init__.py +│ ├── search.py +│ └── result.py +└── tests + └── test_search.py +``` + +The `__init__.py` file turns the `pages` directory into a Python package so that other Python modules can import it. +It will stay permanently empty. +The `search.py` and `result.py` modules will contain the search and result page object classes respectively. + +Let's implement the search page first. +We will use much of our original code. + +A page object class typically has three main parts: + +1. Dependency injection of the browser automator through a constructor +2. Locators and any other data stored as variables +3. Interaction methods that use the browser automator and the selectors + +Let's add these one at a time. +Inside `pages/search.py`, import Playwright's `Page` class: + +```python +from playwright.sync_api import Page +``` + +Add a class definition for the page object: + +```python +class DuckDuckGoSearchPage: +``` + +Inside this class, add the DuckDuckGo URL: + +```python + URL = 'https://www.duckduckgo.com' +``` + +> *Warning:* +> Base URLs should typically be passed into automation code as an input, not hard-coded in a page object. +> We are doing this here as a matter of simplicity for this tutorial. + +Next, let's handle dependency injection for the browser automator. +Since each test will have its own Playwright page, we should inject that page. +(If we were using Selenium WebDriver, then we would inject the WebDriver instance.) +Add the following initializer method to the class: + +```python + def __init__(self, page: Page) -> None: + self.page = page +``` + +The `__init__` method is essentially a constructor for Python classes +(but with a bit of nuance that doesn't matter for this tutorial). +It has one argument named `page` for the Playwright page, +which it stores as an instance variable (via `self`). + +Let's also add locators for search page elements to the constructor. +Our test needs locators for the search button and the search input: + +```python + self.search_button = page.locator('#search_button_homepage') + self.search_input = page.locator('#search_form_input_homepage') +``` + +These locators are created once and can be used anywhere. +We can use them to make interactions. + +One interaction our test performs is loading the DuckDuckGo search page. +Here's a method to do that: + +```python + def load(self) -> None: + self.page.goto(self.URL) +``` + +It uses the injected page as well as the `URL` variable. + +The other interaction our test performs is searching for a phrase. +Here's a method to do that: + +```python + def search(self, phrase: str) -> None: + self.search_input.fill(phrase) + self.search_button.click() +``` + +This `search` method uses the page objects to perform the search. +It also takes in the search phrase as an argument so that it can handle any phrase. + +The completed search page object class should look like this: + +```python +from playwright.sync_api import Page + +class DuckDuckGoSearchPage: + + URL = 'https://www.duckduckgo.com' + + def __init__(self, page: Page) -> None: + self.page = page + self.search_button = page.locator('#search_button_homepage') + self.search_input = page.locator('#search_form_input_homepage') + + def load(self) -> None: + self.page.goto(self.URL) + + def search(self, phrase: str) -> None: + self.search_input.fill(phrase) + self.search_button.click() +``` + +We can now refactor the original test case to use this new page object! +Replace this old code: + +```python +def test_basic_duckduckgo_search(page: Page) -> None: + + # Given the DuckDuckGo home page is displayed + page.goto('https://www.duckduckgo.com') + + # When the user searches for a phrase + page.locator('#search_form_input_homepage').fill('panda') + page.locator('#search_button_homepage').click() +``` + +With this new code: + +```python +from pages.search import DuckDuckGoSearchPage + +def test_basic_duckduckgo_search(page: Page) -> None: + search_page = DuckDuckGoSearchPage(page) + + # Given the DuckDuckGo home page is displayed + search_page.load() + + # When the user searches for a phrase + search_page.search('panda') +``` + +The new code must import `DuckDuckGoSearchPage` from the `pages.search` module. +The test then constructs a `DuckDuckGoSearchPage` object and uses it to perform interactions. +Notice that the test case no longer has hard-coded selectors or URLs. +The code is also more self-documenting. + +Rerun the test (`python3 -m pytest tests --headed --slowmo 1000`). +The test should pass. +Nothing has functionally changed for the test: +it still performs the same operations. +Now, it just uses a page object for the search page instead of raw calls. + + +## The result page + +After writing the search page class, the result page class will be straightforward. +It will follow the same structure. +The main difference is that each interaction method in the result page class will return a value +because test assertions will check page values. + +Start by adding the following imports for type checking to `pages/result.py`: + +```python +from playwright.sync_api import Page +from typing import List +``` + +Add the class definition: + +```python +class DuckDuckGoResultPage: +``` + +Add dependency injection with locators: + +```python + def __init__(self, page: Page) -> None: + self.page = page + self.result_links = page.locator('a[data-testid="result-title-a"]') + self.search_input = page.locator('#search_form_input') +``` + +Now, let's add interaction methods. +Since the verifications for the search input and title are simple, +we don't need new methods for those. +The test case function can call the `search_input` locator and the `page` object directly for those. +However, the verification for search result links has some complex code +that should be handled within the page object. +We can break this down into two methods: + +1. A method to get all result link titles as a list. +2. A method to check if the list of result link titles contains a phrase. + +Add the following methods to the class: + +```python + def result_link_titles(self) -> List[str]: + self.result_links.nth(4).wait_for() + return self.result_links.all_text_contents() + + def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool: + titles = self.result_link_titles() + matches = [t for t in titles if phrase.lower() in t.lower()] + return len(matches) >= minimum +``` + +In the first method, the `result_links` locator is used twice. +The first time it is called, +it is concatenated with the N-th element fetcher to wait for at least 5 elements to appear. +The second time it is called, +it gets all the text contents for the elements it finds. + +The second method takes in a search phrase and a minimum limit for matches. +It calls the first method to get the list of titles, +filters the titles using a list comprehension, +and returns a Boolean value indicating if the number of matches meets the minimum threshold. +Notice that this method does **not** perform an assertion. +Assertions should *not* be done in page objects. +They should only be done in test cases. + +The full code for `pages/result.py` should look like this +(after rearranging methods alphabetically): + +```python +from playwright.sync_api import Page +from typing import List + +class DuckDuckGoResultPage: + + def __init__(self, page: Page) -> None: + self.page = page + self.result_links = page.locator('a[data-testid="result-title-a"]') + self.search_input = page.locator('#search_form_input') + + def result_link_titles(self) -> List[str]: + self.result_links.nth(4).wait_for() + return self.result_links.all_text_contents() + + def result_link_titles_contain_phrase(self, phrase: str, minimum: int = 1) -> bool: + titles = self.result_link_titles() + matches = [t for t in titles if phrase.lower() in t.lower()] + return len(matches) >= minimum +``` + +After rewriting the original test case to use `DuckDuckGoResultPage`, +the code in `tests/test_search.py` should look like this: + +```python +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage +from playwright.sync_api import expect, Page + +def test_basic_duckduckgo_search(page: Page) -> None: + search_page = DuckDuckGoSearchPage(page) + result_page = DuckDuckGoResultPage(page) + + # Given the DuckDuckGo home page is displayed + search_page.load() + + # When the user searches for a phrase + search_page.search('panda') + + # Then the search result query is the phrase + expect(result_page.search_input).to_have_value('panda') + + # And the search result links pertain to the phrase + assert result_page.result_link_titles_contain_phrase('panda') + + # And the search result title contains the phrase + expect(page).to_have_title('panda at DuckDuckGo') +``` + +These calls look less "code-y" than the raw Playwright calls. +They read much more like a test case. + +Rerun the test again to make sure everything is still working. + + +## Page object fixtures + +There is one more thing we can do to maximize the value of our new page objects: +we can create fixtures to automatically construct them! +In our current test, we construct them explicitly inside the test function. +If we add more test functions in the future, that construction code will become repetitive. +Page object fixtures will help our code stay concise. + +In pytest, shared fixtures belong in a module under the `tests` directory named `conftest.py`. +Create a new file at `tests/conftest.py`. +The new project directory layout should look like this: + +``` +playwright-python-tutorial +├── pages +│ ├── __init__.py +│ ├── search.py +│ └── result.py +└── tests + ├── conftest.py + └── test_search.py +``` + +Then, add the following code to `tests/conftest.py`: + +```python +import pytest + +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage +from playwright.sync_api import Page + +@pytest.fixture +def result_page(page: Page) -> DuckDuckGoResultPage: + return DuckDuckGoResultPage(page) + +@pytest.fixture +def search_page(page: Page) -> DuckDuckGoSearchPage: + return DuckDuckGoSearchPage(page) +``` + +The two fixtures, `result_page` and `search_page`, +each call the Playwright `page` fixture and use it to construct a page object. +Just like `page`, they have function scope. +If both page object fixtures are called for the same test +(like we will do for `test_basic_duckduckgo_search`), +then they will both receive the same `page` object due to fixture scope. +You can learn more about fixtures from the +[pytest fixtures](https://docs.pytest.org/en/6.2.x/fixture.html) doc page. + +To use these new fixtures, rewrite `tests/test_search.py` like this: + +```python +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage +from playwright.sync_api import expect, Page + +def test_basic_duckduckgo_search( + page: Page, + search_page: DuckDuckGoSearchPage, + result_page: DuckDuckGoResultPage) -> None: + + # Given the DuckDuckGo home page is displayed + search_page.load() + + # When the user searches for a phrase + search_page.search('panda') + + # Then the search result query is the phrase + expect(result_page.search_input).to_have_value('panda') + + # And the search result links pertain to the phrase + assert result_page.result_link_titles_contain_phrase('panda') + + # And the search result title contains the phrase + expect(page).to_have_title('panda at DuckDuckGo') +``` + +Notice a few things: + +* The `search_page` and `result_page` fixtures are declared as arguments for the test function. +* The test function no longer explicitly constructs page objects. +* Each test step is only one line long. + +If you use page objects, then **all interactions should be performed using page objects**. +It is not recommended to mix raw Playwright calls (except `expect` assertions) with page object calls. +That becomes confusing, and it encourages poor practices like dirty hacks and copypasta. +It also causes a test automation project to lose strength from a lack of conformity in design. + +Rerun the test one more time to make sure the fixtures work as expected. +Congratulations! +You have finished refactoring this test case using page objects. diff --git a/tutorial/5-playwright-tricks.md b/tutorial/5-playwright-tricks.md new file mode 100644 index 0000000..3aa7fdb --- /dev/null +++ b/tutorial/5-playwright-tricks.md @@ -0,0 +1,224 @@ +# Part 5: Nifty Playwright tricks + +Now that we have a complete test case, we can use it to explore some of Playwright's nifty features. +Part 5 of this tutorial will explore various things like testing different browsers, capturing images, and running tests in parallel. +Check the [Pytest plugin](https://playwright.dev/python/docs/test-runners) page +in the Playwright docs to learn even more advanced tricks after this tutorial. + + +## Testing different browsers + +So far, we have done all of our testing using Chromium, which is the default browser for Playwright. +Testing Firefox and WebKit, the other two browsers bundled with Playwright, is as easy as adding a commmand line option. +Use the `--browser` option with the `pytest-playwright` plugin like this: + +```bash +$ python3 -m pytest tests --browser chromium +$ python3 -m pytest tests --browser firefox +$ python3 -m pytest tests --browser webkit +``` + +Give these a try and see what happens. +You may want to append `--headed --slowmo 1000` so you can actually see the automation at work. + +You can also run tests against multiple browsers at the same time. +Just add as many `--browser` options as you want to the same invocation. +Pytest will treat each browser as a test case parameter. +For example, you can run all three browsers like this: + +```bash +$ python3 -m pytest tests --browser chromium --browser firefox --browser webkit --verbose +``` + +The extra `--verbose` option is not necessary, +but adding it will make pytest list each test result with its browser so you can see the parameterization. + +Sometimes, for whatever reason, you may want to test against stock browsers on your machine instead of Chromium, Firefox, or WebKit. +Playwright enables you to test Google Chrome and Microsoft Edge through +[browser channels](https://playwright.dev/python/docs/browsers/#google-chrome--microsoft-edge). +Use the `--browser-channel` option like this: + +```bash +$ python3 -m pytest tests --browser-channel chrome +$ python3 -m pytest tests --browser-channel msedge +``` + +Unfortunately, at the time of developing this tutorial (March 2022), +Playright does not support channels for browsers other than Chrome and Edge. + +Playwright also allows you to emulate mobile devices to test responsive layouts. +The full list of available devices is +[here](https://github.com/microsoft/playwright/blob/master/packages/playwright-core/src/server/deviceDescriptorsSource.json), +and it's quite long! +To test one of these devices, use the `--device` option like this: + +```bash +$ python3 -m pytest tests --device "iPad Mini" +$ python3 -m pytest tests --browser webkit --device "iPhone 11" +$ python3 -m pytest tests --browser chromium --device "Pixel 5" +``` + +Give it a try with `--headed --slowmo 1000` to see the drastically different screen sizes. + + +## Capturing screenshots and videos + +While developing automated tests, +we typically run them headed in slow motion so that we can see exactly what our test code does. +However, when we run tests "for real" in a Continuous Integration system or on a regular schedule, +we are not present in the moment of a failure to see exactly what went wrong. +We rely on test reports, logs, and other artifacts to provide the information needed for root cause analysis. +Since web UI tests are inherently visual, +artifacts like screenshots and videos can be invaluable for determining failure reasons. +A picture is truly worth a thousand words! + +You can capture screenshots at any time using the +[screenshot](https://playwright.dev/python/docs/api/class-page#page-screenshot) method. +However, the most valuable screenshots to capture are typically the ones that happen at the moment of failure. +When a test fails, these happen at the end of the test. +The `pytest-playwright` plugin provides a `--screenshot` option that will capture screenshots at the end of each test. +By default, this option is set to `off`, but you can turn it `on` like this: + +```bash +$ python3 -m pytest tests --screenshot on +``` + +This will capture a screenshot after every test. +Give it a try! +Playwright will save screenshots as PNG files under a directory named `test-results`. +You can change the output directory using the `--output` option. + +The screenshot should look like this: + +![Test screenshot](images/test-screenshot.png) + +Try capturing screenshots with different browsers and devices. +(Please note that Playwright will delete all old results in the output folder before each run.) + +Even though individual screenshot files may be small, +screenshots can add up over time to become a very large data storage burden. +Imagine a large test suite running thousands of tests daily. +Saving a screenshot for every test becomes impractical. +Instead of using `on` to save a screenshot for every test, +use `only-on-failure` to save a screenshot for every *failing* test only: + +```bash +$ python3 -m pytest tests --screenshot only-on-failure +``` + +If you run this command, then you should not get a screenshot for the `test_basic_duckduckgo_search` +because it should pass. + +What could be better than automatic screenshots for tests? +How about *videos*! +If a picture is worth a thousand words, then a video recording must be worth a million. +Yet again, the `pytest-playwright` plugin makes automation easy by providing an option named `--video`. +By default, this option is set to `off`, +but we can set it to `on` to save a video recording for every test, +or we can set it to `retain-on-failure` to save video recordings only for failed tests. + +Give video recording a try with this command: + +```bash +$ python3 -m pytest tests --video on +``` + +Playwright saves videos as [WebM](https://en.wikipedia.org/wiki/WebM) files in the output directory. + +Just like with screenshots, saving videos for every test becomes imprudent over time. +It is recommended to use the `retain-on-failure` option: + +```bash +$ python3 -m pytest tests --video retain-on-failure +``` + + +## Running tests in parallel + +Even though Playwright is pretty fast (especially compared to Selenium WebDriver), +running tests one at a time becomes very slow for large test suites. +Individual tests can only be optimized so far. +Running tests in parallel becomes a necessity. + +While Playwright does not provide parallel execution capabilities on its own, +we can use the `pytest-xdist` plugin to run tests in parallel, +and we can rely on Playwright browser contexts and pages to keep tests isolated. + +Install `pytest-xdist` via pip: + +```bash +$ pip3 install pytest-xdist +``` + +Let's parameterize our test so that we have multiple test to run in parallel. +Change the code in `tests/test_search.py` to match the following: + +```python +import pytest + +from pages.result import DuckDuckGoResultPage +from pages.search import DuckDuckGoSearchPage +from playwright.sync_api import expect, Page + + +ANIMALS = [ + 'panda', + 'python', + 'polar bear', + 'parrot', + 'porcupine', + 'parakeet', + 'pangolin', + 'panther', + 'platypus', + 'peacock' +] + + +@pytest.mark.parametrize('phrase', ANIMALS) +def test_basic_duckduckgo_search( + phrase: str, + page: Page, + search_page: DuckDuckGoSearchPage, + result_page: DuckDuckGoResultPage) -> None: + + # Given the DuckDuckGo home page is displayed + search_page.load() + + # When the user searches for a phrase + search_page.search(phrase) + + # Then the search result query is the phrase + expect(result_page.search_input).to_have_value(phrase) + + # And the search result links pertain to the phrase + assert result_page.result_link_titles_contain_phrase(phrase) + + # And the search result title contains the phrase + expect(page).to_have_title(f'{phrase} at DuckDuckGo') +``` + +The test is the same, but now it is parameterized to use ten different search phrases. + +Try running these new tests serially to see how long they take. +Then, try running them in parallel using `pytest-xdist`'s `-n` option. +The number you give with `-n` specifies the degree of concurrency. +For example, you can run 2 tests in parallel like this: + +```bash +$ python3 -m pytest tests -n 2 +``` + +Typically, the optimal degree of concurrency is the number of processors or cores on your machine. +Try running these tests in parallel at different degrees of concurrency (2, 3, 4, 5, higher?) +to find the fastest completion time. + +> *Warning:* DuckDuckGo may throttle your tests' requests if they happen too quickly. +> To work around this problem, try running with `--headed` or with `--slowmo 100`.) + +You can also test multiple browsers in parallel. +For example, the following command will run the parameterized tests against all three Playwright browsers at 5x parallel: + +```bash +$ python3 -m pytest tests -n 5 --browser chromium --browser firefox --browser webkit +``` diff --git a/tutorial/6-api-testing.md b/tutorial/6-api-testing.md new file mode 100644 index 0000000..cf27a03 --- /dev/null +++ b/tutorial/6-api-testing.md @@ -0,0 +1,582 @@ +# Part 6: API testing + +Did you know that Playwright has support for built-in +[API testing](https://playwright.dev/python/docs/api-testing)? +While you could use Playwright for purely testing APIs, +this feature shines when used together with web UI testing. + +In this part, we will learn how to use Playwright's API testing features by automating tests +for [GitHub project boards](https://docs.github.com/en/issues/organizing-your-work-with-project-boards). +These tests will be more complex than our previous DuckDuckGo search test. +They will make multiple calls to the [GitHub API](https://docs.github.com/en/rest) with authentication. +The first test will *create* a new project card purely from the GitHub API, +and the second test will *move* a project card from one column to another. + +![GitHub Project Cards](images/github-project-cards.png) + + +## API setup + +Before we can develop tests for GitHub project boards, +we need to set up a few things: + +1. A GitHub account +2. A GitHub user project +3. A GitHub personal access token + +Pretty much every developer these days already has a GitHub account, +but not every developer may have set up a project board in GitHub. +Follow the [user project instructions](https://docs.github.com/en/issues/organizing-your-work-with-project-boards/managing-project-boards/creating-a-project-board#creating-a-user-owned-project-board) +to create a user project. +Create a "classic" project and not a *Beta* project. +Use the "Basic Kanban" template – the project must have at least two columns. +The project may be public or private, +but I recommend making it private if you intend to use it only for this tutorial. + +GitHub API calls require a *personal access token* for authentication. +GitHub no longer supports "basic" authentication with username and password. +Follow the [personal access token instructions](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) +to create a personal access token. +Select the **repo** and **user** permissions. +Remember to copy and save your token somewhere safe, +because you won't be able to view it again! + +The tests also need the following four inputs: + +1. Your GitHub username +2. Your GitHub password (for UI login) +3. Your GitHub personal access token (for API authentication) +4. Your GitHub user project name + +Let's set these inputs as environment variables +and read them into tests using pytest fixtures. +Environment variables must be set before launching tests. + +On macOS or Linxu, use the following commands: + +```bash +$ export GITHUB_USERNAME= +$ export GITHUB_PASSWORD= +$ export GITHUB_ACCESS_TOKEN= +$ export GITHUB_PROJECT_NAME="" +``` + +On Windows: + +```console +> set GITHUB_USERNAME= +> set GITHUB_PASSWORD= +> set GITHUB_ACCESS_TOKEN= +> set GITHUB_PROJECT_NAME="" +``` + +> *Warning:* +> Make sure to keep these values secure. +> Do not share your password or access token with anyone. + +We should read these environment variables through pytest fixtures +using the [`os`](https://docs.python.org/3/library/os.html) module +so that any test can easily access them. +Add the following function for reading environment variables to `tests/conftest.py`: + +```python +import os + +def _get_env_var(varname: str) -> str: + value = os.getenv(varname) + assert value, f'{varname} is not set' + return value +``` + +This function will not only read an environment variable by name +but also make sure the variable has a value. +Then, add fixtures for each environment variable: + +```python +@pytest.fixture(scope='session') +def gh_username() -> str: + return _get_env_var('GITHUB_USERNAME') + +@pytest.fixture(scope='session') +def gh_password() -> str: + return _get_env_var('GITHUB_PASSWORD') + +@pytest.fixture(scope='session') +def gh_access_token() -> str: + return _get_env_var('GITHUB_ACCESS_TOKEN') + +@pytest.fixture(scope='session') +def gh_project_name() -> str: + return _get_env_var('GITHUB_PROJECT_NAME') +``` + +Now, our tests can safely and easily fetch these variables. +These fixtures have *session* scope so that pytest will read them only one time during the entire testing session. + +The last thing we need is a Playwright request context object tailored to GitHub API requests. +We could build individual requests for each endpoint call, +but then we would need to explicitly set things like the base URL and the authentication token on each request. +Instead, with Playwright, we can build an +[`APIRequestContext`](https://playwright.dev/python/docs/api/class-apirequestcontext) +tailored to GitHub API requests. +Add the following fixture to `tests/conftest.py` to build a request context object for the GitHub API: + +```python +from playwright.sync_api import Playwright, APIRequestContext +from typing import Generator + +@pytest.fixture(scope='session') +def gh_context( + playwright: Playwright, + gh_access_token: str) -> Generator[APIRequestContext, None, None]: + + headers = { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"token {gh_access_token}"} + + request_context = playwright.request.new_context( + base_url="https://api.github.com", + extra_http_headers=headers) + + yield request_context + request_context.dispose() +``` + +Let's break down the code: + +1. The `gh_context` fixture has *session* scope because the context object can be shared by all tests. +2. It requires the `playwright` fixture for creating a new context object, + and it requires the `gh_access_token` fixture we just wrote for getting your personal access token. +3. GitHub API requests require two headers: + 1. An `Accept` header for proper JSON formatting + 2. An `Authorization` header that uses the access token +4. `playwright.request.new_context(...)` creates a new `APIRequestContext` object + with the base URL for the GitHub API and the headers. +5. The fixture yields the new context object and disposes of it after testing is complete. + +Now, any test or other fixture can call `gh_context` for building GitHub API requests! +All requests created using `gh_context` will contain this base URL and these headers by default. + + + +## Writing a pure API test + +Our first test will create a new project card exclusively using the [GitHub API](https://docs.github.com/en/rest). +The main part of the test has only two steps: + +1. Create a new card on the project board. +2. Retrieve the newly created card to verify that it was created successfully. + +However, this test will need more than just two API calls. +Here is the endpoint for creating a new project card: + +``` +POST /projects/columns/{column_id}/cards +``` + +Notice that to create a new card with this endpoint, +we need the ID of the target column in the desired project. +The column IDs come from the project data. +Thus, we need to make the following chain of calls: + +1. [Retrieve a list of user projects](https://docs.github.com/en/rest/reference/projects#list-user-projects) + to find the target project by name. +2. [Retrieve a list of project columns](https://docs.github.com/en/rest/reference/projects#list-project-columns) + for the target project to find column IDs. +3. [Create a project card](https://docs.github.com/en/rest/reference/projects#create-a-project-card) + in one of the columns using its IDs. +4. [Retrieve the project card](https://docs.github.com/en/rest/reference/projects#get-a-project-card) + using the card's ID. + +> The links provided above for each request document how to make each call. +> They also include example requests and responses. + +The first two requests should be handled by fixtures +because they could (and, for our case, *will*) be used for multiple tests. +Furthermore, all of these requests require +[authentication](https://docs.github.com/en/rest/overview/other-authentication-methods) +using your personal access token. + +Let's write a fixture for the first request to find the target project. +Add this code to `conftest.py`: + +```python +from playwright.sync_api import expect + +@pytest.fixture(scope='session') +def gh_project( + gh_context: APIRequestContext, + gh_username: str, + gh_project_name: str) -> dict: + + resource = f'/users/{gh_username}/projects' + response = gh_context.get(resource) + expect(response).to_be_ok() + + name_match = lambda x: x['name'] == gh_project_name + filtered = filter(name_match, response.json()) + project = list(filtered)[0] + assert project + + return project +``` + +The `gh_project` fixture has *session* scope +because we will treat the project's existence and name as immutable during test execution. +It uses the `gh_context` fixture to build requests with authentication, +and it uses the `gh_username` and `gh_project_name` fixtures for finding the target project. +To get a list of all your projects, +it makes a `GET` request to `/users/{gh_username}/projects` using `gh_context`, +which automatically includes the base URL, headers, and authentication. +The subsequent `expect(response).to_be_ok()` call makes sure the request was successful. +If anything went wrong, tests would abort immediately. + +The resulting response will contain a list of *all* user projects. +This fixture then filters the list to find the project with the target project name. +Once found, it asserts that the project object exists and then returns it. + +Let's write a fixture for the next request in the call chain to get the list of columns for our project. +Add the following code to `conftest.py`: + +```python +@pytest.fixture() +def project_columns( + gh_context: APIRequestContext, + gh_project: dict) -> list[dict]: + + response = gh_context.get(gh_project['columns_url']) + expect(response).to_be_ok() + + columns = response.json() + assert len(columns) >= 2 + return columns +``` + +The `project_columns` fixture uses *function* scope. +In theory, columns could change during testing, +so each test should fetch a fresh column list. +It uses the `gh_context` fixture for making requests, +and it uses the `gh_project` fixture to get project data. +Thankfully, the project data includes a full endpoint URL to fetch the project's columns: `columns_url`. +This fixture makes a `GET` request on that URL. +Then, it verifies that the project has at least two columns before returning the column data. + +This fixture returns the full column data. +However, for testing card creation, we only need a column ID. +Let's make it simple to get column IDs directly with yet another fixture: + +```python +@pytest.fixture() +def project_column_ids(project_columns: list[dict]) -> list[str]: + return list(map(lambda x: x['id'], project_columns)) +``` + +The `project_column_ids` fixture uses the `map` function to get a list of IDs from the list of columns. +We could have fetched the columns and mapped IDs in one fixture, +but it is better to separate them into two fixtures because they represent separate concerns. +Furthermore, while our current test only requires column IDs, +other tests may need other values from column data. + +Now that all the setup is out of the way, let's automate the test! +Create a new file named `tests/test_github_project.py`, +and add the following import statement: + +```python +import time +from playwright.sync_api import APIRequestContext, Page, expect +``` + +We'll need the `time` module to grab timestamps. +We'll need the Playwright stuff for type checking and assertions. + +Define a test function for our card creation test: + +```python +def test_create_project_card( + gh_context: APIRequestContext, + project_column_ids: list[str]) -> None: +``` + +Our test will need `gh_context` to make requests and `project_column_ids` to pick a project column. + +Every new card should have a note with a unique message +so that we can find cards when we need to interact with them. +One easy way to create unique messages is to append a timestamp value, like this: + +```python + now = time.time() + note = f'A new task at {now}' +``` + +Then, we can create a new card in our project via an API call like this: + +```python + c_response = gh_context.post( + f'/projects/columns/{project_column_ids[0]}/cards', + data={'note': note}) + expect(c_response).to_be_ok() + assert c_response.json()['note'] == note +``` + +We use `gh_context` to make the `POST` request to the resource. +The column for the card doesn't matter, +so we can choose the first column for simplicity. +Immediately after receiving the response, +we should make sure the response is okay and that the card's note is correct. + +Finally, we should verify that the card was actually created successfully +by attempting to `GET` the card using its ID: + +```python + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json() == c_response.json() +``` + +The card's ID comes from the previous response. +Again, we use `gh_context` to make the request, +and we immediately verify the correctness of the response. +The response data should be identical to the response data from the creation request. + +That completes our API-only card creation test! +Here's the complete code for the `test_create_project_card` test function: + +```python +import time +from playwright.sync_api import APIRequestContext, Page, expect + +def test_create_project_card( + gh_context: APIRequestContext, + project_column_ids: list[str]) -> None: + + # Prep test data + now = time.time() + note = f'A new task at {now}' + + # Create a new card + c_response = gh_context.post( + f'/projects/columns/{project_column_ids[0]}/cards', + data={'note': note}) + expect(c_response).to_be_ok() + assert c_response.json()['note'] == note + + # Retrieve the newly created card + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json() == c_response.json() +``` + +Run the new test module directly: + +```bash +$ python3 -m pytest tests/test_github_project.py +``` + +Make sure all your environment variables are set correctly. +The test should pass very quickly. + + +## Writing a hybrid UI/API test + +Our second test will move a card from one project column to another. +In this test, we will use complementary API and UI interactions to cover this behavior. +Here are our steps: + +1. Prep the test data +2. Create a new card (API) +3. Log into the GitHub website (UI) +4. Load the project page (UI) +5. Move the card from one column to another (UI) +6. Verify the card is in the second column (UI) +7. Verify the card change persisted to the backend (API) + +Thankfully we can reuse many of the fixtures we created for the previous test. +Even though the previous test created a card, we must create a new card for this test. +Tests can run individually or out of order. +We should not create any interdependencies between individual test cases. + +Let's dive directly into the test case. +Add a new test function with all these fixtures: + +```python +def test_move_project_card( + gh_context: APIRequestContext, + gh_project: dict, + project_column_ids: list[str], + page: Page, + gh_username: str, + gh_password: str) -> None: +``` + +Moving a card requires two columns: the source column and the destination column. +For simplicity, let's use the first two columns, +and let's create convenient variables for their IDs: + +```python + source_col = project_column_ids[0] + dest_col = project_column_ids[1] +``` + +Just like in the previous test, we should write a unique note for the card to create: + +```python + now = time.time() + note = f'Move this card at {now}' +``` + +The code to create a card via the GitHub API is pretty much the same as before, too: + +```python + c_response = gh_context.post( + f'/projects/columns/{source_col}/cards', + data={'note': note}) + expect(c_response).to_be_ok() +``` + +Now, it's time to switch from API to UI. +We need to log into the GitHub website to interact with this new card. +Log into GitHub like this, using fixtures for username and password: + +```python + page.goto(f'https://github.com/login') + page.locator('id=login_field').fill(gh_username) + page.locator('id=password').fill(gh_password) + page.locator('input[name="commit"]').click() +``` + +These interactions use `Page` methods we saw before in our DuckDuckGo search test. +Then, once logged in, navigate directly to the project page: + +```python + page.goto(f'https://github.com/users/{gh_username}/projects/{gh_project["number"]}') +``` + +Direct URL navigation is faster and simpler than clicking through elements on pages. +We can retrieve the GitHub project number from the project's data. +(*Warning:* the project number for the URL is different from the project's ID number.) + +For safety and sanity, we should check that the first project column has the card we created via API: + +```python + card_xpath = f'//div[@id="column-cards-{source_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() +``` + +The card XPath is complex. +Let's break it down: + +1. `//div[@id="column-cards-{source_col}"]` locates the source column `div` using its ID +2. `//p[contains(text(), "{note}")]` locates a child `p` that contains the text of the target card's note + +The assertion is also a bit complex. +Let's break it down, too: + +1. `expect(...)` is a special Playwright function for assertions on page locators. +2. `page.locator(card_xpath)` is a web element locator for the target card. +3. `to_be_visible()` is a condition method for the `expect` assertion. + It verifies that the "expected" locator's element is visible on the page. + +Since the locator includes the source column as the parent for the card's paragraph, +asserting its visibility on the page is sufficient for verifying correctness. +If we only checked for the paragraph element without the parent column, +then the test would not detect if the card appeared in the wrong column. +Furthermore, Playwright assertions will automatically wait up to a timeout for conditions to become true. + +Now, we can perform the main interaction: +moving the card from one column to another. +Playwright provides a nifty +[`drag_and_drop`](https://playwright.dev/python/docs/api/class-page#page-drag-and-drop) method: + +```python + page.drag_and_drop(f'text="{note}"', f'id=column-cards-{dest_col}') +``` + +This call will drag the card to the destination column. +Here, we can use a simpler locator for the card because we previously verified its correct placement. + +After moving the card, we should verify that it indeed appears in the destination column: + +```python + card_xpath = f'//div[@id="column-cards-{dest_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() +``` + +Finally, we should also check that the card's changes persisted to the backend. +Let's `GET` that card's most recent data via the API: + +```python + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json()['column_url'].endswith(str(dest_col)) +``` + +The way to verify the column update is to check the new ID in the `column_url` value. + +Here's the completed test code for `test_move_project_card`: + +```python +import time +from playwright.sync_api import APIRequestContext, Page, expect + +def test_move_project_card( + gh_context: APIRequestContext, + gh_project: dict, + project_column_ids: list[str], + page: Page, + gh_username: str, + gh_password: str) -> None: + + # Prep test data + source_col = project_column_ids[0] + dest_col = project_column_ids[1] + now = time.time() + note = f'Move this card at {now}' + + # Create a new card via API + c_response = gh_context.post( + f'/projects/columns/{source_col}/cards', + data={'note': note}) + expect(c_response).to_be_ok() + + # Log in via UI + page.goto(f'https://github.com/login') + page.locator('id=login_field').fill(gh_username) + page.locator('id=password').fill(gh_password) + page.locator('input[name="commit"]').click() + + # Load the project page + page.goto(f'https://github.com/users/{gh_username}/projects/{gh_project["number"]}') + + # Verify the card appears in the first column + card_xpath = f'//div[@id="column-cards-{source_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() + + # Move a card to the second column via web UI + page.drag_and_drop(f'text="{note}"', f'id=column-cards-{dest_col}') + + # Verify the card is in the second column via UI + card_xpath = f'//div[@id="column-cards-{dest_col}"]//p[contains(text(), "{note}")]' + expect(page.locator(card_xpath)).to_be_visible() + + # Verify the backend is updated via API + card_id = c_response.json()['id'] + r_response = gh_context.get(f'/projects/columns/cards/{card_id}') + expect(r_response).to_be_ok() + assert r_response.json()['column_url'].endswith(str(dest_col)) +``` + +Run this new test. +If you want to see the browser in action, included the `--headed` option. +The test will take a few seconds longer than the pure API test, +but both should pass! + +> *Warning:* +> You might want to periodically archive cards in your GitHub project +> that are created by these tests. + +Complementing UI interactions with API calls is a great way to optimize test execution. +Instead of doing all test steps through the UI, which is slower and more prone to race conditions, +certain actions like pre-loading data or verifying persistent changes can be handled with API calls. diff --git a/tutorial/images/github-project-cards.png b/tutorial/images/github-project-cards.png new file mode 100644 index 0000000..022b6b9 Binary files /dev/null and b/tutorial/images/github-project-cards.png differ diff --git a/tutorial/images/test-screenshot.png b/tutorial/images/test-screenshot.png new file mode 100644 index 0000000..37c1468 Binary files /dev/null and b/tutorial/images/test-screenshot.png differ