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/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 index f99f314..fb5ef24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,22 @@ 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 Page +from playwright.sync_api import Playwright, APIRequestContext, Page, expect +from typing import Generator +# ------------------------------------------------------------ +# DuckDuckGo search fixtures +# ------------------------------------------------------------ @pytest.fixture def result_page(page: Page) -> DuckDuckGoResultPage: @@ -17,4 +26,93 @@ def result_page(page: Page) -> DuckDuckGoResultPage: @pytest.fixture def search_page(page: Page) -> DuckDuckGoSearchPage: - return DuckDuckGoSearchPage(page) \ No newline at end of file + 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 8ec49b8..a9c762f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -2,12 +2,30 @@ These tests cover DuckDuckGo searches. """ +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: @@ -16,14 +34,13 @@ def test_basic_duckduckgo_search( search_page.load() # When the user searches for a phrase - search_page.search('panda') + search_page.search(phrase) # Then the search result query is the phrase - expect(result_page.search_input).to_have_value('panda') + 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('panda') + 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/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