Python Context Managers and the with Statement for Playwright (expect_popup, expect_download)

The with statement appears in almost every Playwright Python program: it starts Playwright, waits for new tabs, catches downloads and opens files. It is powered by context managers, objects that run setup code when a block starts and cleanup code when it ends, even if the block fails. In this lesson you will learn how they work, use Playwright’s built-in ones, and write your own for timing and logging test steps.

Advertisement

📖 What You’ll Learn: what the with statement does, __enter__ and __exit__, why cleanup runs even after an exception, Playwright’s context managers (sync_playwright, expect_popup, expect_download), writing context managers with a class and with contextlib.contextmanager, and how pytest yield fixtures use the same idea.


🚪 What the with Statement Does

You have already used with to work with files. This program (files_demo.py) writes a small test data file and reads it back:

📄 files_demo.py

with open("users.txt", "w", encoding="utf-8") as f:
    f.write("standard_user\n")
    f.write("problem_user\n")

print("File closed after the block?", f.closed)

with open("users.txt", encoding="utf-8") as f:
    users = f.read().splitlines()

print(users)

🖥️ Output

File closed after the block? True
['standard_user', 'problem_user']
  • open(...) returns a file object, and as f gives it a name for the block.
  • When the indented block ends, the file is closed automatically: you never forget f.close().
  • splitlines() turns the text into a list of lines without the \n characters.

Any object that knows how to “start” and “finish” a block like this is a context manager.


🔧 How It Works: __enter__ and __exit__

A context manager is an object with two special methods. Python calls __enter__ when the block starts and __exit__ when it ends. This demo (enter_exit.py) prints each step, including what happens when the block raises an error:

📄 enter_exit.py

class Step:
    def __init__(self, name: str) -> None:
        self.name = name

    def __enter__(self) -> "Step":
        print(f"START {self.name}")
        return self                      # this becomes the 'as' variable

    def __exit__(self, exc_type, exc_value, traceback) -> bool:
        status = "FAILED" if exc_type else "OK"
        print(f"END   {self.name}: {status}")
        return False                     # False = let any exception continue


with Step("open login page"):
    print("  ...doing the work")

try:
    with Step("submit form"):
        raise ValueError("button not found")
except ValueError as error:
    print("Caught after cleanup:", error)

🖥️ Output

START open login page
  ...doing the work
END   open login page: OK
START submit form
END   submit form: FAILED
Caught after cleanup: button not found

Line by line:

  1. __enter__ runs first; whatever it returns is assigned to the variable after as.
  2. __exit__ always runs when the block ends, normally or through an exception. Its three parameters describe the exception (exc_type is None when there was none).
  3. Returning False from __exit__ means “do not swallow the error”, so the ValueError still reaches the except block, after the cleanup printed FAILED.

⚠️ Watch out: If __exit__ returns True, the exception is silently swallowed. In test code that hides real failures, so return False (or None) unless you truly mean to suppress an error.


🎭 Context Managers in Playwright

Starting Playwright

with sync_playwright() as p: starts the Playwright driver in __enter__ and stops it in __exit__. That is why standalone scripts in Lesson 3 never leave a Playwright process running, even when they crash.

Waiting for a new tab: expect_popup

When a click opens a new tab, you must start waiting for it before the click, otherwise the tab might open before you are listening. Playwright solves this with a context manager: the waiting starts when the block begins, and the result is available when the block ends. The practice page’s Open Google link opens in a new tab (target="_blank"). Save this as tests/test_popup.py:

📄 tests/test_popup.py

import re

from playwright.sync_api import Page, expect


def test_link_opens_new_tab(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")

    with page.expect_popup() as popup_info:              # 1. start waiting
        page.get_by_role("link", name="Open Google").click()   # 2. trigger it

    new_tab = popup_info.value                           # 3. the new Page
    new_tab.wait_for_load_state()

    print("\nNew tab URL:", new_tab.url.split("?")[0])
    expect(new_tab).to_have_url(re.compile(r"google\."))
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")
    new_tab.close()
  1. page.expect_popup() returns a context manager. Entering it starts listening for a popup.
  2. The click inside the block opens the new tab.
  3. When the block ends, Playwright waits (up to the default timeout) for the popup and stores it in popup_info.value. It is an ordinary Page, so you can use locators and assertions on it.

The original page is untouched and still on the practice page. You will learn more popup and tab techniques in Module 4.

Waiting for a download: expect_download

Downloads work the same way. To keep the example self-contained, the test builds a tiny page with a download link using page.set_content() (tests/test_download.py):

📄 tests/test_download.py

from pathlib import Path

from playwright.sync_api import Page


def test_download_report(page: Page, tmp_path: Path):
    page.set_content(
        '<a href="data:text/plain,id,name%0A1,Deepesh" download="report.csv">Download report</a>'
    )

    with page.expect_download() as download_info:        # start waiting
        page.get_by_role("link", name="Download report").click()

    download = download_info.value
    target = tmp_path / download.suggested_filename      # report.csv
    download.save_as(target)

    print("\nSaved:", target.name, "->", target.read_text().splitlines())
    assert download.suggested_filename == "report.csv"
    assert target.read_text().splitlines() == ["id,name", "1,Deepesh"]
  • page.set_content(html) replaces the page with your own HTML; handy for small, reliable demos.
  • The download attribute on the link makes the browser download the data instead of opening it. %0A is an encoded line break.
  • download.suggested_filename is the name the site proposed; save_as() copies the file where you want it.
  • tmp_path is a built-in pytest fixture that gives each test its own temporary folder, cleaned up automatically.

Other Playwright waiters follow the same pattern: page.expect_response(...), page.expect_request(...), page.expect_file_chooser(), page.expect_console_message() and context.expect_page().

Run both tests:

⌨️ Terminal

pytest -q -s tests/test_popup.py tests/test_download.py

🖥️ Output

New tab URL: https://www.google.com/
.
Saved: report.csv -> ['id,name', '1,Deepesh']
.
2 passed in 3.42s

🛠️ Write Your Own with contextlib

Writing a class with __enter__ and __exit__ works, but for most cases the contextlib.contextmanager decorator is shorter: you write a generator function with one yield. Code before yield is the setup, code after it is the cleanup. This helper times a test step (tests/test_timed_steps.py):

📄 tests/test_timed_steps.py

import time
from contextlib import contextmanager
from typing import Iterator

from playwright.sync_api import Page, expect


@contextmanager
def timed_step(name: str) -> Iterator[None]:
    start = time.perf_counter()
    print(f"\n> {name}")
    try:
        yield                                   # the body of the with block runs here
    finally:
        seconds = time.perf_counter() - start   # always runs, even if the step fails
        print(f"< {name} took {seconds:.1f}s")


def test_login_with_timed_steps(page: Page):
    with timed_step("open login page"):
        page.goto("https://www.saucedemo.com/")

    with timed_step("log in"):
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill("secret_sauce")
        page.get_by_role("button", name="Login").click()

    with timed_step("check products page"):
        expect(page.locator(".title")).to_have_text("Products")
  • @contextmanager turns the generator function into a context manager.
  • Iterator[None] is the type hint for a generator that yields nothing useful.
  • try/finally around yield guarantees the cleanup line runs even when a step inside the with block raises an error. Without it, a failure would skip the timing message.
  • time.perf_counter() is a precise clock for measuring durations.

When you run it with -s, each step reports its duration (your timings will differ):

🖥️ Output

> open login page
< open login page took 0.5s

> log in
< log in took 0.1s

> check products page
< check products page took 0.1s

🔗 The Same Idea in pytest Fixtures

pytest fixtures that use yield follow exactly this setup / yield / cleanup shape:

import pytest


@pytest.fixture
def temp_user():
    user = {"name": "qa_user"}      # setup: before the test
    yield user                      # the test runs here and receives 'user'
    user.clear()                    # cleanup: after the test, even if it failed

pytest-playwright’s own page fixture works this way: it creates a context and page, yields the page to your test, and closes them afterwards. You will write many fixtures like this in Module 3.


❓ Frequently Asked Questions

🤔 Why can’t I just click and then call page.wait_for_event(“popup”)?

Because the popup may already have opened before you start waiting, and then you would wait until the timeout. Starting the wait first with with page.expect_popup() removes that race.

🤔 Can one with statement manage several context managers?

Yes: with open("a.txt") as a, open("b.txt") as b:. Both are cleaned up in reverse order when the block ends.

🤔 What is the difference between a context manager and a fixture?

A context manager wraps a block of code inside a function. A pytest fixture wraps a whole test and is requested by name. Fixtures with yield use the same setup/cleanup pattern.

🤔 Is there an async version of with?

Yes, async with, used with the async API (async with async_playwright() as p:). You will see it in the next lesson.


🏁 Summary

  • with calls __enter__ at the start and __exit__ at the end, even after an exception.
  • Playwright uses context managers to start the driver and to wait for popups, downloads, responses and more: start waiting, trigger the action, then read .value.
  • Write your own with @contextmanager plus try/finally around a single yield.
  • pytest’s yield fixtures apply the same pattern to whole tests.

Next, you will learn Python’s async/await and asyncio, the foundation of Playwright’s async API.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

1 thought on “Python Context Managers and the with Statement for Playwright (expect_popup, expect_download)”

Leave a Comment