Playwright with Python - A Quick Guide
Tags: playwright, testing, python, e2e
Published: 2024-06-14 21:45:54

In this guide, we'll see how to set up a basic Playwright project using Python and Pytest. Then, how to implement the Page Object Pattern and a few other things.

This guide requires a basic knowledge of Python.

## Preconditions

The packages required are:

- `playwright`

- `pytest-playwright`

- `pytest-xdist`


## The folder structure

The base folder structure for our project:

```
/pages
/tests
conftest.py
requirements.txt
```

## Installation

We add the required packages to the requirements.txt file:

**requirements.txt**
```
playwright>=1.44.0
pytest-playwright>=0.5.0
pytest-xdist>=3.6.1
```

Then we run the following command:

`pip install -r requirements.txt`

Note: instead of `pip`, `pip3` might be required depending on the OS.

Then:

`playwright install`

This command will install the required browsers.

## Adding a Basic Test

A basic login test using Playwright:

**/tests/test_login.py**

```python
from playwright.sync_api import Page, expect


def test_login_success(page: Page):
    page.goto('https://react-redux.realworld.io/#/login')
    page.get_by_placeholder('Email').type('test_playwright_login@test.com')
    page.get_by_placeholder('Password').type('Test123456')
    page.get_by_role('button', name='Sign in').click()
    expect(page.get_by_role('link', name='test_playwright_login')).to_be_visible()
```

### Running this test

This runs a single test in headed mode. Playwright runs in headless mode by default.

`pytest -k test_login_success --headed`

### Running all the tests

`pytest`

### Selecting the browsers

`pytest --browser webkit --browser firefox`


## Implementing the Page Object Pattern

To start using the POM we add:

**pages/login_page.py**

```python
from playwright.sync_api import Page


class Login:
    
    def __init__(self, page: Page):
        self.page = page
        self.email_input = page.get_by_placeholder('Email')
        self.password_input = page.get_by_placeholder('Password')
        self.signin_button = page.get_by_role('button', name='Sign in')
    
    def goto(self):
        self.page.goto('/#/login')
```

**pages/navbar_page.py**

```python
from playwright.sync_api import Page


class Navbar:
    
    def __init__(self, page: Page):
        self.page = page
    
    def user_link(self, username: str):
        return self.page.get_by_role('link', name=username)
```

We add the base URL of our app to the conftest.py as a fixture like this:

**conftest.py**

```python
import pytest


@pytest.fixture(scope='session')
def base_url():
    return 'https://react-redux.realworld.io/'
```


And now the test looks like this:

**tests/test_login.py**

```python
from playwright.sync_api import Page, expect

from pages.login_page import Login
from pages.navbar_page import Navbar


def test_login_success(page: Page):
    login = Login(page)
    navbar = Navbar(page)
    login.goto()
    login.email_input.type('test_playwright_login@test.com')
    login.password_input.type('Test123456')
    login.signin_button.click()
    expect(navbar.user_link('test_playwright_login')).to_be_visible()
```

### Using Pytest fixtures to Instantiate the Page Objects

Instead of instantiating the page objects in each test, we use [Pytest fixtures](https://docs.pytest.org/en/latest/explanation/fixtures.html).

We add the following to conftest.py

**conftest.py**
```python
import pytest
from playwright.sync_api import Page
from pages.login_page import Login
from pages.navbar_page import Navbar


@pytest.fixture(scope='session')
def base_url():
    return 'https://react-redux.realworld.io/'


@pytest.fixture
def page(page: Page) -> Page:
    timeout = 10000
    page.set_default_navigation_timeout(timeout)
    page.set_default_timeout(timeout)
    return page


@pytest.fixture
def login(page) -> Login:
    return Login(page)

@pytest.fixture
def navbar(page) -> Navbar:
    return Navbar(page)
```

**Note**: we use the "page" fixture to define timeouts.

Now the test looks like this:

**tests/test_login.py**

```python
from playwright.sync_api import expect


def test_login_success(login, navbar):
    login.goto()
    login.email_input.type('test_playwright_login@test.com')
    login.password_input.type('Test123456')
    login.signin_button.click()
    expect(navbar.user_link('test_playwright_login')).to_be_visible()
```

## Removing User Data Values from the Test

We create a users.py file to store user's data, just one user for now. The DictObject is a utility class to access dictionary values using object notation. It could be moved elsewhere, but for now, we keep it here.

**users.py**

```python
import json


class DictObject(object):
    def __init__(self, dict_):
        self.__dict__.update(dict_)

    @classmethod
    def from_dict(cls, d):
        return json.loads(json.dumps(d), object_hook=DictObject)


USERS = DictObject.from_dict({
    'user_01': {
        'username': 'test_playwright_login',
        'email': 'test_playwright_login@test.com',
        'password': 'Test123456'
    }
})
```

And we update the test:

**tests/test_login.py**

```python
from playwright.sync_api import expect

from users import USERS

def test_login_success(login, navbar):
    login.goto()
    login.email_input.type(USERS.user_01.email)
    login.password_input.type(USERS.user_01.password)
    login.signin_button.click()
    expect(navbar.user_link(USERS.user_01.username)).to_be_visible()
```


## Running tests in parallel

To run the tests in parallel we use pytest-xdist. We should already have it installed by this point.

`pytest -n 5`

Where `-n` is the number of workers.


## Managing Environment Data

Usually, we want the tests to run in different environments. So we want to set the base URL based on the selected env.

We add these changes to the conftest.py file:

**conftest.py**

```python
def pytest_addoption(parser):
    parser.addoption("--env", action="store", default="staging")


@pytest.fixture(scope='session', autouse=True)
def env_name(request):
    return request.config.getoption("--env")


@pytest.fixture(scope='session')
def base_url(env_name):
    if env_name == 'staging':
        return 'https://react-redux.realworld.io/'
    elif env_name == 'production':
        return 'https://react-redux.production.realworld.io/'
    else:
        exit('Please provide a valid environment')
```

After this, we can pass as an argument the environment name like this:

`pytest --env staging`

## Defining Global Before and After Test

```python
@pytest.fixture(scope="function", autouse=True)
def before_each_after_each(page: Page, base_url):
    # The code here runs before each test
    print(‘before the test runs’)

    # Go to the starting url before each test.
    page.goto(base_url)
    yield
    
    # This code runs after each test
    print(‘after the test runs’)
```

This fixture can be added to the conftest.py and apply it to every test. Or it can be defined inside a single test module and be applied only to the tests inside that module.

## Tagging the Tests

To tag the tests we can use Pytest [marker feature](https://docs.pytest.org/en/stable/example/markers.html#marking-test-functions-and-selecting-them-for-a-run).

```python
import pytest

@pytest.mark.login
def test_login_success():
    # ...
```

We must register our custom markers in the pytest.ini file (a new file added in the root folder).

**pytest.ini**
```ini
[pytest]
markers =
    login: mark test as a login test.
    slow: mark test as slow.
```

And to run a custom marker/tag we use:

`pytest -m login`

## Tooling

### [Codegen](https://playwright.dev/python/docs/codegen-intro#running-codegen)

This generates a test capturing actions in real time. It's useful for generating locators and assertions. But if we are using POM, then the generated code needs to be refactored into it.

### [Playwright Inspector](https://playwright.dev/python/docs/running-tests#debugging-tests)

This is a debugger util that enables running a test step by step, among other things.

### [Trace Viewer](https://playwright.dev/python/docs/trace-viewer-intro)

The trace viewer records the result of a test so it can be reviewed later with a live preview of each action performed. This is super useful specially when running tests from CI.