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.
📖 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, andas fgives 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\ncharacters.
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:
__enter__runs first; whatever it returns is assigned to the variable afteras.__exit__always runs when the block ends, normally or through an exception. Its three parameters describe the exception (exc_typeisNonewhen there was none).- Returning
Falsefrom__exit__means “do not swallow the error”, so theValueErrorstill reaches theexceptblock, after the cleanup printedFAILED.
⚠️ 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()
page.expect_popup()returns a context manager. Entering it starts listening for a popup.- The click inside the block opens the new tab.
- When the block ends, Playwright waits (up to the default timeout) for the popup and stores it in
popup_info.value. It is an ordinaryPage, 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
downloadattribute on the link makes the browser download the data instead of opening it.%0Ais an encoded line break. download.suggested_filenameis the name the site proposed;save_as()copies the file where you want it.tmp_pathis 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")
@contextmanagerturns the generator function into a context manager.Iterator[None]is the type hint for a generator that yields nothing useful.try/finallyaroundyieldguarantees the cleanup line runs even when a step inside thewithblock 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
withcalls__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
@contextmanagerplustry/finallyaround a singleyield. - 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)”