Playwright Python API Testing
Playwright Python API 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.
## API setup
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.
```bash
$ export GITHUB_USERNAME=<github-username>
$ export GITHUB_PASSWORD=<github-password>
$ export GITHUB_ACCESS_TOKEN=<github-access-token>
$ export GITHUB_PROJECT_NAME="<github-project-name>"
```
On Windows:
```console
> set GITHUB_USERNAME=<github-username>
> set GITHUB_PASSWORD=<github-password>
> set GITHUB_ACCESS_TOKEN=<github-access-token>
> set GITHUB_PROJECT_NAME="<github-project-name>"
```
> *Warning:*
> Make sure to keep these values secure.
> Do not share your password or access token with anyone.
```python
import os
```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()
```
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.
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:
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
```
> The links provided above for each request document how to make each call.
> They also include example requests and responses.
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()
return project
```
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
```
```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
```
```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
```
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()
```
```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:
```bash
$ python3 -m pytest tests/test_github_project.py
```
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:
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.
```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()
```
```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()
```
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.
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.
```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:
# 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()
> *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.