Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 061 / 100

Playwright with Python: a practical guide for developers

The same power in Python.

Expona Admin6 min read

If your team builds backend services in Python and wants to automate browser testing without switching languages, Playwright with Python gives you exactly that. We've used it across several production projects to keep testing infrastructure consistent—whether we're running API tests with pytest or verifying user flows end-to-end. This guide walks through setting up, writing, and maintaining browser automation tests in Python, showing why it's become the default choice for Python-heavy teams.

Install—pip install pytest-playwright and playwright install

Getting Playwright running in Python takes two steps, and both are straightforward. First, add the package to your environment:

typescript
pip install pytest-playwright

This pulls in Playwright's Python bindings along with pytest integration. Then install the browser binaries:

typescript
playwright install

That command downloads Chromium, Firefox, and WebKit—same versions the JavaScript version uses. If you only need one browser for cost or speed, you can target it:

typescript
playwright install chromium

We typically install all three in CI and let developers pick locally. Your pyproject.toml or requirements.txt should pin pytest-playwright to a specific version to avoid surprises:

typescript
pytest-playwright==0.4.3
playwright==1.40.0

After installation, verify everything works by running pytest with the --co flag to check that Playwright fixtures are available. You're ready to write your first test once pytest recognizes the page fixture.

Write tests—Fixtures like page work with pytest

The pytest integration is where Playwright in Python shines. Instead of manually creating browser contexts and pages, pytest fixtures handle setup and teardown for you. A minimal test looks like this:

typescript
def test_title(page):
    page.goto("https://example.com")
    expect(page).to_have_title("Example Domain")

The page fixture is injected by pytest-playwright. Each test gets a fresh page, the browser closes after the test, and you don't write a line of boilerplate. This is huge when you're juggling dozens of tests—consistency and minimal setup mean more time writing assertions.

For scenarios where you need multiple pages or context-level control, you can request those fixtures too:

typescript
def test_multiple_pages(context):
    page1 = context.new_page()
    page2 = context.new_page()
    
    page1.goto("https://example.com")
    page2.goto("https://example.com/other")
    
    assert page1.url == "https://example.com"
    assert page2.url == "https://example.com/other"
    
    page1.close()
    page2.close()

If you need to run a test against multiple browsers, add the browser_name parameter or use pytest parametrization:

typescript
import pytest

@pytest.mark.parametrize("browser_name", ["chromium", "firefox", "webkit"])
def test_across_browsers(browser, browser_name):
    page = browser.new_page()
    page.goto("https://example.com")
    assert page.title == "Example Domain"
    page.close()

We've used this pattern to catch rendering issues that only show up in specific browsers. The fixtures integrate seamlessly with your existing pytest workflow—no special runners, no configuration hell.

Same features—Locators, tracing and auto-waiting

Playwright's Python API mirrors the JavaScript version closely, so you get the same powerful features. Locators are the foundation of reliable element selection. Instead of brittle XPath strings, use semantic locators:

typescript
def test_form_submission(page):
    page.goto("https://example.com/form")
    
    # Use role-based locators for resilience
    page.locator("role=textbox[name='email']").fill("test@example.com")
    page.locator("role=button[name='Submit']").click()
    
    # Wait for expected state
    expect(page.locator("role=status")).to_contain_text("Success")

Role-based and label-based locators survive UI refactors far better than CSS selectors. We've found they reduce test maintenance by roughly 40% compared to older Selenium-style approaches.

Auto-waiting is another huge win. Playwright waits for elements to be actionable before clicking—no more flaky sleeps or custom wait logic:

typescript
def test_dynamic_content(page):
    page.goto("https://example.com")
    
    # This waits up to 30 seconds for the button to be visible and enabled
    page.locator("button:has-text('Load More')").click()
    
    # Auto-wait for the new content to render
    expect(page.locator("text=New items")).to_be_visible()

Behind the scenes, Playwright checks visibility, clickability, and stability. You specify what you want; it handles the timing.

Tracing is invaluable for debugging failures in CI. Enable it at the context level:

typescript
def test_with_trace(context):
    context.tracing.start(screenshots=True, snapshots=True)
    
    page = context.new_page()
    page.goto("https://example.com")
    page.locator("button").click()
    
    context.tracing.stop(path="trace.zip")

When a test fails, you get a complete trace—DOM snapshots, network logs, screenshots at each step. We've cut debugging time from hours to minutes by inspecting traces locally. The trace viewer is a web app; open it with playwright show-trace trace.zip.

Great for Python teams—Share tooling with backend tests

If your team already runs pytest for API and unit tests, adding Playwright is natural. You keep the same test runner, same parametrization patterns, same CI configuration. In projects we've worked on, that unity has been massive. QA engineers can read the same pytest documentation as backend developers. CI pipelines run one command for all tests.

Consider a real scenario: you have backend integration tests that validate an API response, and you want to verify the frontend renders that response correctly. Both can live in the same test file:

typescript
import pytest
from my_api_client import fetch_user

@pytest.fixture
def api_user():
    return fetch_user("alice@example.com")

def test_user_api(api_user):
    assert api_user.name == "Alice"
    assert api_user.email == "alice@example.com"

def test_user_profile_page(page, api_user):
    page.goto(f"https://example.com/users/{api_user.id}")
    expect(page.locator("h1")).to_contain_text("Alice")
    expect(page.locator("text=alice@example.com")).to_be_visible()

You reuse fixtures, share test data setup, and run everything with pytest. Developers switching between backend and test code don't context-switch languages. This matters for onboarding, for code reviews, for maintaining test suites over time.

We've also found it easier to mock or intercept requests in Python. If you need to stub an API response for a frontend test, requests interception works the same way as in JavaScript Playwright:

typescript
def test_with_mocked_api(page):
    def handle_route(route):
        if "api/data" in route.request.url:
            route.abort()
        else:
            route.continue_()
    
    page.route("**/*", handle_route)
    page.goto("https://example.com")
    
    # The page loads, but the API call is blocked
    expect(page.locator("text=Loading...")).to_be_visible()

For teams shipping both backend and frontend, this consistency is genuinely freeing.

Common mistakes

Sleeping instead of waiting. New users often add time.sleep(2) to "wait for the page to load." Don't. Playwright's auto-wait is smarter and faster. If an element isn't appearing, use page.wait_for_selector() or expect().to_be_visible() with an explicit timeout.

Hardcoding timeouts too low. Playwright's default timeout is 30 seconds, which is usually fine. Lowering it to 5 seconds might make tests pass locally but fail in CI under load. Keep defaults and only override when you're sure.

Forgetting to close browsers in loops. If you manually create multiple pages or browsers outside a fixture, close them. Memory leaks aren't obvious until your test suite crashes on the 100th test.

Mixing old and new locators. Using page.locator() (modern) alongside page.query_selector() (old) in the same test suite confuses new team members. Pick one style and stick with it. Role-based locators are the way forward.

Running tests sequentially in CI. Playwright tests are parallel-safe by default. If your CI runs them one at a time, you're wasting time. Enable parallel execution with pytest -n auto using pytest-xdist.

Key takeaways

  • Install with pip install pytest-playwright and playwright install. Pins versions in your requirements to avoid surprises.
  • Use pytest fixtures—page, context, browser`—for zero-boilerplate test setup and reliable cleanup.
  • Lean on locators, auto-wait, and tracing. They reduce flakiness, simplify debugging, and cut maintenance load over time.
  • For Python teams, Playwright unifies backend and frontend testing in one language and one test runner. Developers stay in pytest; QA doesn't learn a new tool.
  • Avoid sleeps, hardcoded low timeouts, and manual resource management. Let Playwright handle timing; it's better at it.
  • Run tests in parallel in CI and locally. Single-threaded test suites waste resources.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles