← All interview Q&A
Free · Top 25 questions

Playwright (Python) interview questions

The 25 Playwright Python questions interviewers keep asking: pytest fixtures, the sync API, locators, auto waiting and the comparisons you have to get right.

Each answer has three parts: Concept (the real difference), Interview Strategy (how to say it in the room), and Code (a quick glimpse). This is the same format as my paid kits.

300+ questions in the full kit

SDET Playwright Python Foundation Kit 2026

A glimpse of the full Foundation Kit: 311 questions across 33 sections, Python for SDETs, locators, auto waiting, fixtures, network mocking, API testing, CI/CD and AI with MCP, each section closing with its own quiz.

01

Playwright Python vs Selenium Python: what is the real difference?

Mid
Concept

Both drive a real browser from Python, so the job is the same. The difference is architecture and what comes in the box. Selenium speaks the W3C WebDriver protocol, historically one HTTP request per command, and it leaves synchronisation to you, which is why a Selenium suite fills up with WebDriverWait. Playwright keeps one connection open to the browser and runs actionability checks before every action, so most of that waiting code simply is not needed. Playwright also ships its own browser builds, the trace viewer, codegen and network mocking, all from one pip install plus a browser download. Selenium still owns the larger job pool in India, the widest language support, and mature Grid and real device clouds.

Interview Strategy

Lead with architecture and synchronisation, because that single difference explains the flakiness gap. Then be genuinely fair about where Selenium still wins, since running down either tool reads as inexperience. The senior signal is naming Selenium's BiDi work, which shows you know the gap has narrowed.

How to phrase it

Both drive a real browser from Python, so I frame this as an architecture question rather than one tool beating the other. Selenium speaks the WebDriver protocol, historically an HTTP request per command, and it does not wait for you, so a Selenium suite ends up full of WebDriverWait and explicit conditions. Playwright holds one connection open to the browser and runs actionability checks before every click and fill, so it waits for the element to be visible, stable and enabled on its own. That removes a whole category of flakiness that most people were never taught to handle in Selenium. Playwright also brings things I would otherwise bolt on myself: its own browser builds, the trace viewer, codegen and network mocking, from one pip install plus a browser download. Where I stay fair is the market. Selenium has the bigger job pool here in India, the widest language support, and mature Grid and real device clouds, and Selenium's BiDi work has narrowed the protocol gap. So for a new Python suite I reach for Playwright, and where Selenium already runs well I leave it running.

Key points to hit

  • Same job, different architecture: a WebDriver request per command against one open connection.
  • Playwright runs actionability checks before every action, so most explicit waits are not needed.
  • Playwright bundles browsers, the trace viewer, codegen and network mocking in one install.
  • Selenium still wins on job volume in India, language breadth, and Grid or device clouds.
  • Selenium BiDi has narrowed the gap, so choose per team and existing stack, not by tribe.
Code
# Selenium: the wait is your job
WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable((By.ID, "pay"))
).click()

# Playwright Python: auto-waits, no wait code at all
page.get_by_role("button", name="Pay").click()
02

pytest-playwright vs the raw playwright library: what does the plugin give you?

Junior
Concept

They are layers, not rivals. The playwright pip package is the automation library: on its own you open sync_playwright yourself, launch a browser, create a context, create a page and close everything in the right order. pytest-playwright is the official pytest plugin that wraps all of that in fixtures, so a test just asks for page and the plugin owns setup and teardown. It also adds command line options, browser choice, headed mode, slow motion, base URL, tracing, video and screenshot, and writes those artifacts per test into the test-results folder. For a pytest suite the plugin is the normal choice; the raw library is for standalone scripts.

Interview Strategy

Say the library is the automation and the plugin is the plumbing, then name three concrete things the plugin hands you: the page fixture, the command line flags, and per test artifacts. The trap is treating them as competitors when one sits on top of the other.

How to phrase it

They are layers, not rivals, so I explain both. The playwright package is the automation itself. If I use only that, I open sync_playwright as a context manager, launch a browser, create a context, create a page, and then close all of it in the right order, and I repeat that in every file or hand roll my own helper. pytest-playwright is the official pytest plugin that turns all of that plumbing into fixtures. My test signature just says page, and the plugin launches the browser, gives each test a fresh context, and cleans up afterwards even when the test fails. On top of that it gives me command line control I would otherwise have to write myself: the browser flag to pick chromium, firefox or webkit, headed mode so I can watch it, a base URL so page.goto can take just a path, and tracing, video and screenshot options that drop artifacts per test into the test-results folder. So for a pytest suite I always use the plugin. I reach for the raw library when I am writing a standalone script or driving Playwright from something that is not pytest.

Key points to hit

  • playwright is the automation library; pytest-playwright is the pytest wiring around it.
  • Raw library: you launch the browser, build the context and page, and close them yourself.
  • The plugin gives fixtures, so a test just names page and gets clean setup and teardown.
  • The plugin adds --browser, --headed, --base-url, --tracing, --video and --screenshot.
  • Use the plugin for pytest suites, the raw library for standalone scripts.
Code
# Raw library: you own the whole lifecycle
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com")
    browser.close()

# With pytest-playwright: the fixture owns it
def test_home(page):
    page.goto("https://example.com")
03

Sync API vs async API in Playwright Python: which one do you use?

Mid
Concept

Playwright Python ships two complete APIs over the same engine. The sync API from playwright.sync_api reads top to bottom with no await, and it is what pytest-playwright gives you and what codegen writes. The async API from playwright.async_api uses async def and await, and under pytest it needs the async plugin, pytest-playwright-asyncio, which ships async versions of the same fixtures. The two do not mix: calling the sync API from inside a running event loop raises an error. Async pays off when you want many pages working at once inside one process, or when the code already lives in an async application. For an ordinary suite the speed comes from pytest-xdist processes, so sync stays simpler.

Interview Strategy

State a clear default, the sync API, and give the one real reason to move: many pages in flight inside a single process, or an already async codebase. The detail that lands is that you cannot call the sync API from inside a running event loop.

How to phrase it

I default to the sync API, and I can say exactly when I would move. The sync API reads straight down the page with no await anywhere, it is what pytest-playwright hands me, and it is what codegen writes, so anyone on the team can read a test on the first pass. The async API is the same engine with async def and await, and under pytest it needs the async plugin, pytest-playwright-asyncio, which gives me async versions of the same fixtures. The rule I make sure to mention is that the two do not mix. If I call the sync API from inside a running event loop, Playwright raises an error and tells me to use the async API instead. So where is async genuinely worth it? When I want many pages doing work at the same time inside one process, say driving twenty contexts to warm data or check links, because then I can await them together and the wall clock time drops a lot. Also when my code already sits inside an async application that runs its own loop. For a normal suite I get my speed from pytest-xdist running several processes, so sync stays the simpler choice.

Key points to hit

  • Two complete APIs over one engine: playwright.sync_api and playwright.async_api.
  • Sync is the default for pytest-playwright and for codegen output, and reads top to bottom.
  • Async needs async def, await and the pytest-playwright-asyncio plugin under pytest.
  • You cannot call the sync API from inside a running event loop; Playwright raises an error.
  • Async pays off for many pages at once in one process, or an already async codebase.
  • For a normal suite, pytest-xdist processes give the speed, so sync stays simpler.
Code
# Sync: the default, no await anywhere
def test_title(page):
    page.goto("/")
    assert "Home" in page.title()

# Async: pip install pytest-playwright-asyncio
import pytest

@pytest.mark.asyncio
async def test_title_async(page):
    await page.goto("/")
    assert "Home" in await page.title()
04

What does the page fixture actually give you, and why not launch a browser yourself?

Junior
Concept

page is a function scoped fixture from pytest-playwright. For every test it creates a brand new browser context and one page inside it, and it closes that context when the test ends, pass or fail. A fresh context means fresh cookies, fresh local storage and fresh permissions, so no test can leak a login or a half filled cart into the next one. Launching a browser yourself works, but then you own the ordering, the cleanup when an assertion fails halfway, and the isolation, and that is where hand rolled setups quietly start sharing state. Browsers are expensive to launch and contexts are cheap, which is why the browser is shared and the context is not.

Interview Strategy

Define it in one line, a new context and page per test with automatic teardown, then make isolation the headline, because that is what stops tests leaking into each other. Add the cost point, contexts are cheap and browsers are not, which explains the whole design.

How to phrase it

The page fixture is the reason a Playwright Python test can be four lines long. It is function scoped, so before each test pytest-playwright creates a completely new browser context and one page inside it, and after the test it closes that context whether the test passed or failed. The word I lean on is isolation. A new context means new cookies, new local storage and new permissions, so a test that logs in as an admin cannot leave that session behind for the next test, and a half filled cart cannot leak forward. That is the single biggest cause of tests that pass on their own and fail in a suite, and most people were never taught that the context is what protects them. Could I launch a browser myself? Yes, and in a standalone script I sometimes do. But then I own the launch order, the cleanup when an assertion fails halfway, and the isolation, and that is exactly where hand rolled setups start sharing state by accident. The other detail I add is cost. Launching a browser is genuinely slow, so it is shared for the session, while creating a context takes milliseconds, so a fresh one per test costs almost nothing.

Key points to hit

  • page is function scoped: a new context and a new page for every single test.
  • A fresh context means fresh cookies, storage and permissions, so tests cannot leak state.
  • Teardown runs even when the test fails, so nothing is left open behind you.
  • Contexts are cheap and browsers are expensive, which is why the browser is shared.
  • Hand rolled setups have to own ordering, cleanup and isolation themselves.
Code
from playwright.sync_api import expect

# One fresh context and page per test, cleaned up for you
def test_login_lands_on_dashboard(page):
    page.goto("/login")
    page.get_by_label("Email").fill("sree@example.com")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
05

browser vs context vs page fixture: what is shared and what is fresh?

Mid
Concept

pytest-playwright gives you three layers. browser is session scoped, so one browser process is launched once and reused by every test in the run. context is function scoped, a fresh isolated profile per test with its own cookies, storage and permissions. page is function scoped too, a single tab inside that context. The shape follows cost against isolation: launching a browser takes real time, creating a context takes milliseconds. To change how the browser launches you override browser_type_launch_args, and to change how every context is built you override browser_context_args, both session scoped, in conftest.py.

Interview Strategy

Give the three scopes in order and tie each one to cost and isolation. Then show you know the override hooks, browser_type_launch_args and browser_context_args, because that is the part interviewers use to tell reading from doing.

How to phrase it

I answer it as three layers with two different scopes. The browser fixture is session scoped, so the browser process starts once at the beginning of the run and every test in that worker reuses it. The context fixture is function scoped, so each test gets a brand new isolated profile with its own cookies, local storage and permissions. The page fixture is function scoped as well, and it is just one tab inside that context. The reason for the split is cost against isolation. Launching a browser is genuinely slow, so I want that once. Creating a context is a few milliseconds, so I can afford a clean one for every test, and that is what keeps tests independent of each other. The part I make sure to mention is how I change the defaults, because that is the day to day work. To launch differently, say slow motion or a proxy, I override browser_type_launch_args in conftest.py. To change every context, say a viewport size, a locale, or a saved login, I override browser_context_args. Both are session scoped, and I spread the existing value and add my own keys rather than replacing the whole dictionary, so I keep whatever the plugin already set from my command line flags.

Key points to hit

  • browser is session scoped: one browser process for the whole run.
  • context and page are function scoped: a clean profile and a clean tab per test.
  • The split follows cost, browsers are slow to launch and contexts are cheap.
  • A fresh context per test is what keeps cookies and storage from leaking forward.
  • Override browser_type_launch_args and browser_context_args in conftest.py.
  • Spread the existing value so you keep what your command line flags already set.
Code
# conftest.py
import pytest

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "viewport": {"width": 1440, "height": 900},
        "locale": "en-IN",
    }
06

get_by_role vs CSS vs XPath: which locator do you reach for first?

Junior
Concept

get_by_role queries the accessibility tree, the same structure a screen reader reads, so it finds a control by what it is and what it is called rather than by where it sits in the markup. A CSS selector targets structure, classes, ids and attributes, so it breaks when a developer renames a class. XPath can walk upward and sideways through the tree, which is powerful and also the most brittle, because a small markup change moves the whole path. Playwright recommends an order: get_by_role, then get_by_label, get_by_placeholder and get_by_text, then get_by_test_id, and CSS or XPath only when nothing user facing fits.

Interview Strategy

Recommend Playwright's order out loud and give the reason: a role based locator survives markup churn and doubles as a basic accessibility check. Then be honest that CSS still has a place and XPath is a last resort, never a badge of skill.

How to phrase it

I follow the order Playwright recommends, and I can say why each step is there. I start with get_by_role, because it reads the accessibility tree, which is the same thing a screen reader uses. So I am asking for a button named Pay, not for a div with a particular class, and that survives a developer renaming a class or wrapping things in another container. There is a free bonus too. If get_by_role cannot find the button, a screen reader cannot either, so a failure there is often a real accessibility defect rather than a test problem. After roles I use get_by_label, get_by_placeholder and get_by_text, which are still user facing, and then get_by_test_id when there is genuinely no accessible name, like an icon only control or a purely visual badge. CSS I keep for structural cases where there is nothing user facing to hold on to, a chart container for example. XPath is my last resort. It is the only one that can walk upward through the tree, which is occasionally useful, but a small markup change moves the whole path, so I never treat a long XPath as a sign of skill.

Key points to hit

  • get_by_role reads the accessibility tree, so it finds a control by what it is and what it is called.
  • Role based locators survive class renames and double as a basic accessibility check.
  • The order: role, then label, placeholder and text, then test id, then CSS, then XPath.
  • CSS is fine for a structural hook with no user facing name, like a chart container.
  • XPath can walk up the tree but is the most brittle, so treat it as a last resort.
Code
# Preferred: behaviour led, reads the accessibility tree
page.get_by_role("button", name="Add to cart").click()
page.get_by_label("Email").fill("sree@example.com")

# Structural fallback when nothing user facing fits
page.locator("#cart-badge").click()

# Last resort
page.locator("xpath=//tr[td[text()='Order 4001']]//button").click()
07

expect() from playwright.sync_api vs a bare assert: what changes?

Mid
Concept

A bare assert checks a value once, at the instant Python evaluates it. Read text into a variable and then assert on it and you have taken a snapshot, so you are racing the UI. expect, imported from playwright.sync_api, is web first: given a locator it keeps re-checking the condition until it holds or the timeout expires, and only then fails. It also prints a far better message, showing the locator it used, what it was waiting for and what it actually found. The rule that follows is simple: assert on the locator, never on a value you read earlier.

Interview Strategy

State the rule plainly, assert on the locator, then contrast the anti-pattern, reading inner_text into a variable and asserting on it, against to_have_text. Point out that expect comes from playwright.sync_api and is not pytest's assert, because people mix those two up.

How to phrase it

The rule I work to is that I assert on the locator, never on a value I read earlier. A bare Python assert evaluates once, right then. So the pattern I see a lot, reading inner_text of the total into a variable and asserting on that string, is a single snapshot, and if the page updates a fraction of a second later the assertion has already failed with nothing to save it. expect, imported from playwright.sync_api, is web first. I hand it the locator itself and it keeps re-checking the condition, is this visible, does it have this text, until the condition becomes true or the timeout runs out. That retry is what removes a whole bucket of timing flakiness for free, and most people were never shown that difference. The other thing I get is a much better failure message. expect tells me the locator it used, what it was waiting for and what it actually saw, which turns a short traceback into something I can act on straight away. I do keep plain assert for pure Python checks, comparing a computed number or a field I parsed out of an API response, because there is no page there to wait for.

Key points to hit

  • A bare assert evaluates once, so it races the UI and cannot retry.
  • Anti-pattern: read inner_text into a variable, then assert on the variable.
  • expect from playwright.sync_api retries the condition on the locator until it holds or times out.
  • expect prints the locator, the expected condition and the actual value, so failures are readable.
  • Keep plain assert for pure Python values where no page is involved.
Code
from playwright.sync_api import expect

# Races the UI: one snapshot, no retry
# total = page.locator("#total").inner_text()
# assert total == "4000"

# Web first: retries until it matches or times out
expect(page.get_by_test_id("total")).to_have_text("4000")
08

Auto-waiting vs time.sleep: why is sleep still in most suites?

Junior
Concept

Before every action Playwright runs actionability checks. For a click it waits for the element to be attached, visible, stable so it is not still animating, enabled, and able to receive the event, and only then clicks. So for ordinary actions you write no wait at all. time.sleep survives in suites because it is the habit people bring from older tools where nothing waited for them, and because it does turn a red test green the first time. The cost is that a fixed number is wrong in both directions, wasted time when the app is quick and still too short on the day the app is slow. When you do need to wait, wait for a named thing.

Interview Strategy

Name the actionability checks explicitly, then be generous about why sleep is everywhere, it is inherited habit rather than carelessness. Close with the replacements, expect, expect_response and locator.wait_for, so you offer a path forward instead of a scolding.

How to phrase it

Playwright does the waiting for me, and that genuinely surprises people coming from older tools. Before a click it runs actionability checks: it waits for the element to be attached to the DOM, visible, stable so it is not still animating, enabled, and able to actually receive the event. Only then does it click. So for normal actions I write no wait at all. Why is time.sleep still everywhere? Honestly because it is inherited. Most of us learned automation on a stack where nothing waited for us, so sleep was the only tool we had, and it does turn a red test green the first time you try it. The problem is that a fixed number is wrong in both directions. Two seconds is wasted time on a fast day and still not enough on the day the API is slow, so the suite ends up both slower and flakier. What I use instead is always a named condition. expect on a locator retries the assertion for me. If I am waiting on the network I use expect_response. If I need a specific element state I use locator.wait_for with that state. Same intent, but I am waiting for the thing rather than for the clock.

Key points to hit

  • Actionability checks run before each action: attached, visible, stable, enabled, receives events.
  • For ordinary clicks and fills you write no wait code at all.
  • Sleep is inherited habit from tools that never waited, and it does turn red green once.
  • A fixed sleep is wrong both ways: wasted time when fast, still too short when slow.
  • Replace it with expect, expect_response or locator.wait_for, a named condition not a number.
Code
# No wait needed: the click auto-waits
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Saved")).to_be_visible()

# When you really must wait, name the thing
with page.expect_response("**/api/report") as res:
    page.get_by_role("button", name="Run report").click()
assert res.value.ok
09

page.locator is lazy: why does that matter in a React or Angular app?

Mid
Concept

page.locator and the get_by helpers do not touch the DOM when you create them. A locator is a description of how to find something, and Playwright resolves it fresh every time you act on it or assert on it, with auto-waiting each time. In a framework that re-renders, that is exactly what you want: when React swaps the node, the locator simply finds the new one. The older approach, query_selector, hands back an element handle, a reference to one node at one moment, and the moment the DOM changes that handle is detached and the next action throws. Because locators are lazy, you can safely hold them as page object attributes and reuse them all test.

Interview Strategy

Use one line as the frame: a locator is an instruction, an element handle is a reference. Then connect it to the real symptom, the detached element error in a re-rendering app. Add that this is why a locator can live as a page object attribute.

How to phrase it

The mental model I use is that a locator is an instruction and an element handle is a reference. When I write page.get_by_role for the Pay button, nothing has touched the DOM yet. Playwright has only recorded how to find it. Every time I click it, assert on it or read from it, Playwright resolves that description again, fresh, and auto-waits while it does. In a React or Angular app that re-renders constantly, that is exactly the behaviour I want, because when the framework throws away the node and builds a new one, my locator just finds the new one and carries on. The contrast is query_selector, which hands back an element handle pointing at one specific node at one specific moment. As soon as the app re-renders, that node is gone and the next action fails with a detached element error, and people spend hours blaming the application for it when the tool was only ever holding a stale reference. The practical payoff of laziness is that I can define my locators once, as attributes on a page object, and reuse them across the whole test with no risk of them going stale.

Key points to hit

  • A locator is a lazy description: it does not query the DOM until you use it.
  • Playwright re-resolves the locator on every action and assertion, with auto-waiting each time.
  • That is what survives a React or Angular re-render swapping the node.
  • query_selector returns an element handle, a snapshot that goes detached and throws.
  • Because locators are lazy they can live as page object attributes and be reused all test.
Code
class CheckoutPage:
    def __init__(self, page):
        # Defined once here, resolved fresh on every use
        self.pay = page.get_by_role("button", name="Pay")
        self.total = page.get_by_test_id("total")

    def pay_now(self):
        self.pay.click()
10

Strict mode in Playwright Python: what does it actually protect you from?

Mid
Concept

Locators are strict by default. If a locator matches more than one element, Playwright refuses to act and raises a strict mode violation that lists the matches it found. Compare that with a tool that silently picks the first match, where a locator matching every row in a table clicks row one forever and the test passes while testing something it never claimed to. When several matches are genuinely expected, you narrow with filter or by scoping inside a row, or you choose deliberately with first, last or nth. The error is a design feature, not something to silence with nth(0).

Interview Strategy

Frame the error as a guard rail that stops a test silently acting on the wrong element. Then give the ranked fixes, narrow first and choose deliberately second, and say plainly that putting first on everything only buries the ambiguity.

How to phrase it

Strict mode is one of my favourite things about Playwright, even though it looks like an annoyance the first time it fires. Locators are strict by default, so if my locator matches three elements, Playwright will not act at all. It raises a strict mode violation and lists what it matched. Compare that with tools that quietly take the first match: there, a locator that matches every row in a table will happily click row one forever, the test passes, and nobody notices it was never testing the row it claimed to. So the error is protecting me from a test that lies to me. When it fires, my first move is always to narrow rather than to silence. I scope to the container, so I get the row named Order 4001 and then the Cancel button inside that row, or I use filter with has_text to pick the right one. Only when several matches are genuinely correct and I truly want a particular one do I use first, last or nth, and then it is a deliberate choice I can explain in a review. What I do not do is sprinkle nth(0) around to make the error disappear, because that just buries the ambiguity for the next person.

Key points to hit

  • Locators are strict: more than one match raises a violation instead of acting.
  • Tools that silently take the first match let a test pass while checking the wrong element.
  • First fix is to narrow: scope to the row or container the element lives in.
  • filter with has_text or has is the clean way to pick one of many.
  • first, last and nth are for when several matches are genuinely expected.
  • Adding nth(0) everywhere hides the ambiguity rather than resolving it.
Code
# Strict violation: there are three Cancel buttons on the page
# page.get_by_role("button", name="Cancel").click()

# Narrow to the right row, then act
row = page.get_by_role("row").filter(has_text="Order 4001")
row.get_by_role("button", name="Cancel").click()
11

storage_state vs logging in inside every test: how do you handle login?

Senior
Concept

Logging in through the UI in every test is slow and it spreads risk. Every test pays the full login cost, and when the login flow flakes it fails a test that was really about checkout, so the failure points at the wrong place. With storage_state you log in once, save the cookies and local storage to a JSON file with context.storage_state, then point browser_context_args at that file so every test arrives already authenticated. For several roles you save one state file per role and choose the file with a fixture or a marker. That file holds a live session, so it belongs in gitignore and CI regenerates it at the start of a run.

Interview Strategy

Describe the two steps clearly, save once then load everywhere, and quantify the win: run time drops and login flakiness leaves the rest of the suite. The senior nuance is one state file per role, plus knowing the file holds live session data so it is never committed.

How to phrase it

Logging in inside every test is the first thing I move away from on a real suite, for two reasons. It is slow, because every test pays the full UI login, and it spreads risk, because when the login flow flakes it fails a test that was actually about the cart, so the failure points at the wrong place entirely. The pattern I use instead is storage state. I have one session scoped fixture that logs in through the UI once, then calls context.storage_state with a path and writes the cookies and local storage to a JSON file. After that I point browser_context_args at that file, so every test gets a context that is already authenticated and jumps straight to the behaviour it is actually there to verify. Run time drops sharply and login flakiness disappears from the rest of the suite. The nuance I always add is roles. I save one state file per role, an admin file and a standard user file, and pick the file with a fixture or a marker, so I can cover role based behaviour without re-logging in anywhere. And a practical point that gets missed: that file holds a live session, so it goes in gitignore and CI regenerates it at the start of every run.

Key points to hit

  • Logging in per test is slow and leaks login flakiness into unrelated tests.
  • Log in once, then save cookies and local storage with context.storage_state(path=...).
  • Point browser_context_args at the file so every test starts already authenticated.
  • Save one state file per role and choose it with a fixture or a marker.
  • The file holds a live session: gitignore it and regenerate it at the start of a CI run.
Code
# conftest.py
import pytest

@pytest.fixture(scope="session", autouse=True)
def save_login(browser):
    page = browser.new_page()
    page.goto("https://example.com/login")
    page.get_by_label("Email").fill("sree@example.com")
    page.get_by_label("Password").fill("test-password")
    page.get_by_role("button", name="Sign in").click()
    page.context.storage_state(path="auth/user.json")
    page.close()

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {**browser_context_args, "storage_state": "auth/user.json"}
12

conftest.py and custom fixtures vs setup code in every test file

Mid
Concept

conftest.py is where pytest looks for fixtures automatically. Anything defined there is available to every test in that folder and every folder below it with no import at all, and you can keep a root one for shared things and a smaller one inside a folder for that area only. A custom fixture is a function with the fixture decorator that yields a value: everything before the yield is setup, everything after is teardown, and the teardown runs even when the test fails. Copying setup into each test file means the same code in ten places and ten chances for them to drift apart.

Interview Strategy

Explain the two halves, conftest.py as automatic discovery and yield as setup plus guaranteed teardown, then give scope as the lever for cost. Mention nested conftest files, because that shows you have organised a real suite rather than a folder of tests.

How to phrase it

conftest.py is pytest's answer to shared setup. Anything I define there is discovered automatically, so every test in that folder and every folder below it can just name the fixture in its arguments, with no import at all. I usually keep a root conftest.py for things the whole suite needs, the login state, the base URL, a test data factory, and then a smaller conftest.py inside a folder for setup that only that area cares about, so the root file does not become a dumping ground. The fixture itself is a function with the fixture decorator that yields. Everything before the yield is setup, the test then runs with that value, and everything after the yield is teardown, which pytest runs even when the test fails, so I never leak a created record or an open connection out of a broken test. Scope is the lever I tune: function scope for anything a test can dirty, session scope for expensive things that are genuinely read only. The alternative, setup pasted into every test file, means the same twenty lines in ten places, and they drift, so a fix lands in one file and quietly misses the other nine.

Key points to hit

  • conftest.py fixtures are discovered automatically, with no import in the test.
  • Root conftest.py for suite wide setup, nested ones for a folder's own needs.
  • A yield fixture is setup before the yield and teardown after, and teardown runs on failure too.
  • Scope is the cost lever: function for anything a test can dirty, session for read only setup.
  • Duplicated setup drifts, so a fix lands in one file and misses the rest.
Code
# conftest.py
import pytest

@pytest.fixture
def new_order(api):
    order = api.create_order(total=4000)
    yield order                      # the test runs here
    api.delete_order(order["id"])    # runs even if the test failed


def test_cancel_order(page, new_order):
    page.goto("/orders/" + new_order["id"])
    page.get_by_role("button", name="Cancel").click()
13

Where does a page object belong: a fixture or straight inside the test?

Senior
Concept

A page object in Playwright Python is a plain class that takes page in its constructor, holds locators as attributes, because locators are lazy so they never go stale, and exposes methods named after what a user does. Building it inside the test with one line is fine for a handful of tests. Once the suite grows you expose it as a fixture in conftest.py, so every test that needs it just names it and any setup it needs is wired in one place. The line that matters more is what goes inside the class: locators and actions yes, assertions no, because a page object that asserts hides what the test is verifying.

Interview Strategy

Answer the actual question, a fixture once you have more than a few tests, and give the reason: one wiring point instead of repeated construction. Then make the senior point about keeping assertions out of the page object, which is what separates a designed framework from a folder of classes.

How to phrase it

A page object in Playwright Python is refreshingly plain. It is a class that takes page in its constructor, holds locators as attributes, which is safe precisely because locators are lazy and never go stale, and exposes methods named after what a user does, login, add to cart, pay. On where it goes: for a handful of tests, building it inside the test is honestly fine and I would not over engineer it. Once the suite grows I expose it as a fixture in conftest.py, and the reason is wiring, not tidiness. The fixture is one place where I decide how that page object is constructed, so if tomorrow it needs a seeded order or a specific role, I change one fixture instead of forty tests, and every test just names login_page and gets it. The design line I care about even more is what goes inside the class. Locators and actions belong there. Assertions do not. If the page object asserts, then reading the test tells you nothing about what is being verified, and two tests that need slightly different checks end up fighting over one method. So the class drives the page and the test owns the expectations.

Key points to hit

  • A page object is a plain class: page in the constructor, locators as attributes, actions as methods.
  • Locators are lazy, so holding them as attributes is safe across re-renders.
  • Building it inline is fine for a few tests; a conftest.py fixture pays off as the suite grows.
  • The fixture is one wiring point, so added setup changes one place instead of every test.
  • Keep assertions in the test, not in the page object, so the test still says what it verifies.
Code
# pages/login.py
class LoginPage:
    def __init__(self, page):
        self.page = page
        self.email = page.get_by_label("Email")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")

    def login(self, email, password):
        self.page.goto("/login")
        self.email.fill(email)
        self.password.fill(password)
        self.submit.click()

# conftest.py
@pytest.fixture
def login_page(page):
    return LoginPage(page)
14

Running in parallel with pytest-xdist: what speeds up and what breaks?

Senior
Concept

pytest-playwright does not parallelise on its own. You add pytest-xdist and run with a worker count, and pytest spreads tests across separate worker processes, each launching its own browser, so isolation between workers is real. What breaks is anything shared: one login account used by two workers at once, a fixture that seeds a fixed record id, tests that only passed because they ran in a certain order, and session scoped setup you assumed ran once which now runs once per worker. The fixes are structural, unique data per test and no ordering assumptions, which is what makes a suite healthy anyway.

Interview Strategy

Say the parallelism comes from xdist and not from the plugin, then spend most of the answer on what breaks, because that is where experience shows. Name session scope running once per worker, since that is the detail most people meet the hard way.

How to phrase it

In Python the parallelism comes from pytest-xdist rather than from Playwright. I install it and run with four workers, or with auto to match the cores, and pytest distributes tests across separate worker processes. Each worker launches its own browser, so the workers really are isolated from one another, and on a decent machine the wall clock time drops a lot. The interesting half of this question is what breaks, and it is always shared state. One test account used by two workers at the same time, so one logs the other out. A fixture that creates a record with a fixed id, so the second worker hits a duplicate. Tests that only ever passed because they ran in a certain order, since with xdist that order is no longer what you think. And the one that catches almost everybody: a session scoped fixture runs once per worker, not once per run, so setup I assumed happened a single time now happens four times. So my fixes are structural. Every test creates the data it needs with a unique value, I use a pool of accounts or one account per worker rather than one shared login, and I never let a test depend on another one. That discipline makes the suite healthier even before I turn parallelism on.

Key points to hit

  • Parallelism comes from pytest-xdist, not the plugin: each worker its own process and browser.
  • Shared test accounts break first, one worker logs another one out.
  • Fixed ids or fixed seed data collide across workers.
  • A session scoped fixture runs once per worker, not once per run.
  • Fix it with unique data per test, an account per worker, and no ordering assumptions.
Code
# Four worker processes, each with its own browser
# pytest -n 4 --browser chromium

import uuid

def test_create_order(page):
    ref = "ORD-" + uuid.uuid4().hex[:8]   # unique per test, safe in parallel
    page.goto("/orders/new")
    page.get_by_label("Reference").fill(ref)
    page.get_by_role("button", name="Create").click()
15

pytest.ini vs pyproject.toml: where does Playwright config live in Python?

Junior
Concept

There is no playwright.config file in Python. That belongs to the Node runner. In Python the runner is pytest, so the settings live in pytest's own configuration, either pytest.ini under a pytest section or pyproject.toml under tool.pytest.ini_options. You put your standing flags in addopts so the whole team and CI run the same way without remembering a flag, and you declare markers there too. Anything that is not a command line flag, a viewport, a locale, a saved login, goes in browser_context_args in conftest.py instead. Keep one config file, because if both exist pytest reads pytest.ini.

Interview Strategy

Correct the assumption early, there is no playwright.config in Python, then say where things actually go: addopts for flags, conftest.py for context options. Choosing pyproject.toml on a new project is a small signal that you keep up.

How to phrase it

The first thing I clear up is that there is no playwright.config file in Python. That is the Node runner. In Python the runner is pytest, so my Playwright settings live in pytest's own configuration. I can use pytest.ini with a plain pytest section, or pyproject.toml under tool.pytest.ini_options, and on a new project I prefer pyproject.toml because everything else about the project already lives in that file. If both files exist, pytest reads pytest.ini, so I keep only one and avoid the confusion. What goes in there is addopts, the flags I want on every single run, so the browser, the base URL, tracing set to retain on failure. That matters because then everybody on the team and CI run the same way without anyone having to remember a flag. I also declare my markers there, which turns a misspelt marker into an error instead of a silent pass. The part people miss is that anything which is not a command line flag is not a pytest setting at all. A viewport size, a locale, a saved login state, those go in browser_context_args in conftest.py.

Key points to hit

  • There is no playwright.config in Python; the pytest config is the config.
  • pytest.ini uses a [pytest] section, pyproject.toml uses [tool.pytest.ini_options].
  • If both exist pytest reads pytest.ini, so keep one file.
  • Standing flags go in addopts so the team and CI run identically.
  • Viewport, locale and storage state are not flags, they go in browser_context_args.
Code
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--browser chromium --base-url https://staging.example.com --tracing retain-on-failure"
testpaths = ["tests"]
markers = [
  "smoke: fast checks that must pass on every push",
  "slow: long running end to end flows",
]
16

Markers vs picking tests by name: how do you run just the smoke set?

Mid
Concept

Three levers pick tests. The -k flag matches the test name as text, which is handy at your desk and fragile as a contract, because a rename silently changes what runs. The -m flag matches markers, labels you attach with the pytest mark decorator and declare in your config, so the smoke set is a deliberate label and not an accident of naming. Paths pick by file or folder. For CI, markers are the right tool: you tag once and the pipeline runs the smoke set on every push and the whole suite nightly. Declaring markers in config with strict markers turns a typo into an error.

Interview Strategy

Split it by audience: -k for a human at a keyboard, markers for anything a pipeline depends on. The detail that lands is declaring markers in config with strict markers, so a misspelt marker fails loudly instead of quietly running nothing.

How to phrase it

I split these by who is doing the selecting. When I am at my desk chasing one failure, I use -k with a bit of the test name, because it is fast and I do not care that it is loose. For anything a pipeline depends on, I use markers. I tag tests with the pytest mark decorator, smoke, regression, slow, and then CI runs the smoke marker on every push and the full suite nightly. The reason I never let CI select by name is that names change. Somebody renames a test in a perfectly reasonable refactor, and now the smoke job runs a different set, or nothing at all, and it still reports green. A marker is a deliberate label, so it survives a rename. Two details I always add. I declare my markers in the config file and turn on strict markers, so a misspelt marker is an error rather than a silently empty run, which is the failure mode that really hurts. And markers combine, so smoke and not slow reads exactly like the sentence I mean. I also keep the smoke set honest about its purpose, fast and about the paths that earn money, not simply the tests that happen to be passing.

Key points to hit

  • The -k flag selects on the test name as text, good at your desk, fragile as a contract.
  • The -m flag selects on markers, deliberate labels that survive renames.
  • Declare markers in config and use strict markers so a typo errors instead of running nothing.
  • Markers combine, so smoke and not slow reads like the sentence you mean.
  • Keep the smoke set fast and focused on the paths that earn money.
Code
import pytest

@pytest.mark.smoke
def test_checkout_pays(page):
    page.goto("/cart")
    page.get_by_role("button", name="Pay").click()
    expect(page.get_by_text("Payment successful")).to_be_visible()

# CI: the smoke set only
# pytest -m "smoke and not slow"

# Local, loose, by name
# pytest -k checkout
17

Headed vs headless, and --browser vs --browser-channel

Junior
Concept

These are two separate pairs. Headless runs the browser with no visible window, which is the default and what CI uses; --headed shows the real window so you can watch, and --slowmo adds a delay between actions so you can follow it. The rendering engine is the same in both, so a test that only fails headless usually points at viewport size or timing, not the mode. --browser picks an engine Playwright ships, chromium, firefox or webkit, and you can pass it more than once to run against each. --browser-channel uses a branded build already installed on the machine, chrome or msedge.

Interview Strategy

Keep the two pairs apart and short: headed is for your eyes and headless is the default, while --browser is a Playwright engine and --browser-channel is an installed branded build. The experience signal is saying a headless only failure points at viewport or timing.

How to phrase it

These are two separate pairs, so I keep them apart. Headed against headless is about whether I can see the window. Headless is the default and it is what CI runs, because there is no display there and it is faster. I add the headed flag locally when I want to watch a test and understand what it is doing, often with slow motion so the actions are slow enough for my eyes to follow. The rendering engine is identical in both, so behaviour should not change, and that gives me a useful instinct: if a test only fails headless, I do not blame the mode, I look at the viewport size or at timing, because headless often runs a different window size and runs faster. The second pair is about which browser. The browser flag picks an engine Playwright downloads for me, chromium, firefox or webkit, and I can pass it twice to run the same tests on two engines in one command. The browser channel flag is for a branded build already installed on the machine, chrome or msedge, so I use that when a stakeholder needs to see the flow pass in the real Chrome rather than in Chromium.

Key points to hit

  • Headless is the default and what CI runs; --headed shows the window for your eyes.
  • --slowmo pairs with --headed so you can follow each action.
  • A failure only in headless usually points at viewport size or timing, not the mode.
  • --browser picks a Playwright engine: chromium, firefox or webkit, and repeats for several.
  • --browser-channel uses a branded build installed on the machine, like chrome or msedge.
Code
# Watch it run, slowly, on your machine
# pytest --headed --slowmo 500 -k checkout

# Two engines in one run
# pytest --browser chromium --browser webkit

# The real Chrome installed on this machine
# pytest --browser-channel chrome
18

Trace vs video vs screenshot: what do you turn on in CI?

Mid
Concept

A screenshot is one image at the moment of failure, cheap and enough when a page clearly did not load. A video is the run as a recording, easy for a non technical person to watch, and it tells you what happened but not why. A trace is the one that actually debugs: it records every action with a DOM snapshot before and after it, plus network calls, console output and the line of test code, and you step through it in the trace viewer. In CI the sane setting is tracing retained on failure, screenshots only on failure, so passing tests cost nothing and every failure arrives with a trace zip.

Interview Strategy

Rank them by what they tell you, then give the exact CI setting: tracing retain on failure, screenshot only on failure, video off or retain on failure. Mention uploading test-results as a build artifact and opening the zip with show-trace, because that is the step people forget.

How to phrase it

I rank these by how much they tell me. A screenshot is a single image at the moment things failed. It is cheap and sometimes it is all I need, when a page clearly did not load. A video is the run as a recording, which is genuinely useful for showing a non technical stakeholder what the user saw, but it tells me what happened and not why. The trace is the one that actually debugs. It records every action with a DOM snapshot before and after it, plus the network calls, the console output and the line of test code, and I open it in the trace viewer and step backwards and forwards through the failure as if I were sitting there. So my CI setting is tracing set to retain on failure, screenshots only on failure, and video off, or also retain on failure if the team likes watching them. Retain on failure matters because passing tests then cost nothing in time or disk, and only the failures leave artifacts behind. The last step people forget is uploading the test-results folder as a build artifact, because otherwise the trace lives on a machine that no longer exists. Then on my own machine I run playwright show-trace on the zip.

Key points to hit

  • A screenshot is one image at failure, cheap and enough for an obvious break.
  • A video shows what the user saw, good for sharing, weak for root cause.
  • A trace records every action with DOM snapshots, network, console and the source line.
  • CI setting: tracing retain-on-failure, screenshot only-on-failure, video off or retain on failure.
  • Upload test-results as a build artifact, then open the zip with playwright show-trace.
Code
# In CI
# pytest --tracing retain-on-failure --screenshot only-on-failure --video off

# Then, on your own machine
# playwright show-trace test-results/test-checkout/trace.zip
19

page.route mocking vs expect_response: which one do you reach for?

Mid
Concept

They sound similar and they do opposite jobs. page.route intercepts the request before it leaves the browser and lets you answer it yourself with route.fulfill, so the backend is never touched and the UI receives exactly the payload you wrote. expect_response changes nothing at all: it waits for a response whose URL matches and hands it to you so you can assert on the status or read the body. So route controls what the UI receives, and expect_response observes what really happened. Mock for edge cases a real backend will not produce on demand, and keep a small set of genuinely unmocked tests for full stack confidence.

Interview Strategy

Give the one line difference first: route replaces the response, expect_response watches for it. Then say when each earns its place, route for deterministic edge cases and expect_response for proving the real call happened with the right status.

How to phrase it

They sound similar and they do opposite jobs, so I define both in one line each. page.route intercepts the request before it leaves the browser and lets me answer it myself with route.fulfill, so the backend is never touched and the UI receives exactly the payload I wrote. expect_response changes nothing at all. It simply waits for a response whose URL matches and hands it to me, so I can assert on the status or read the body. I reach for route when I need determinism or an edge case. An empty list, a five hundred error, a malformed record, a very slow response: these are the states where UI bugs hide, and a real backend will not produce them on demand. I reach for expect_response when the point of the test is that the real call happened correctly, so I click Save, wait for the save response and assert it came back with a two hundred, which is a much stronger check than only looking for a toast on screen. And I stay deliberate about balance. I keep a small set of genuinely unmocked end to end tests, because those are the ones that prove the whole system works together, and I mock the rest for speed and for the cases I cannot trigger for real.

Key points to hit

  • page.route intercepts the request and answers it with route.fulfill, so the backend is never reached.
  • expect_response observes: it waits for a matching response and hands it to you to assert on.
  • Mock with route for edge cases a real backend will not give you on demand.
  • Use expect_response when the point is that the real call happened with the right status.
  • Keep a small set of unmocked end to end tests for real full stack confidence.
Code
# Control what the UI receives
page.route("**/api/cart", lambda route: route.fulfill(
    json={"items": [], "total": 0}
))
page.goto("/cart")
expect(page.get_by_text("Your cart is empty")).to_be_visible()

# Observe what really happened
with page.expect_response("**/api/save") as info:
    page.get_by_role("button", name="Save").click()
assert info.value.status == 200
20

A click opens a new tab: how do you handle a popup in Playwright Python?

Mid
Concept

A new tab is simply another Page object inside the same browser context, so it shares cookies and storage and the user is still signed in there. You catch it with the expect_popup context manager: open the block, do the click inside it, and the new page is on the value attribute once the block ends. Clicking, sleeping and then reading context.pages is a race, not a solution. If the site opens the tab itself rather than in response to your click, subscribe to the page event on the context. Once you have the popup, wait for its load state before asserting, and remember the original page object still points at the first tab.

Interview Strategy

Lead with the context manager pattern, because people instinctively click first and then go looking for the tab, which races. Say the popup shares the context so the user is still signed in, and that you keep both page objects straight afterwards.

How to phrase it

The key idea is that a new tab is just another Page object in the same browser context, so it shares cookies and storage and the user is still signed in there. The mistake almost everybody makes first is to click, sleep, and then look at context.pages, which is a race and fails on a slow day. The correct pattern is the expect_popup context manager. I open a with block that says expect_popup, and I do the click inside that block, so Playwright is already listening before the click happens. When the block ends I take the new page off the value attribute. After that I usually wait for its load state before asserting, because it is a fresh page load. Then I keep the two page objects straight: the original page is still the first tab and the new one is the popup, and I can go back to the first at any time without closing the popup. One variation worth mentioning is when the site opens the tab on its own rather than in response to something I clicked. In that case I subscribe to the page event on the context instead of wrapping a click.

Key points to hit

  • A new tab is a new Page in the same context, so cookies and the login carry over.
  • Use the expect_popup context manager and do the click inside the block.
  • The new page arrives on the value attribute after the block ends.
  • Clicking, sleeping and then reading context.pages is a race, not a solution.
  • For a tab the site opens by itself, listen for the page event on the context.
  • Wait for the popup's load state before asserting, and keep both page objects straight.
Code
with page.expect_popup() as info:
    page.get_by_role("link", name="Open report").click()

report = info.value
report.wait_for_load_state()
expect(report.get_by_role("heading", name="Report")).to_be_visible()

# The original tab is still usable
page.get_by_role("button", name="Back to orders").click()
21

File upload and download: set_input_files vs expect_download

Mid
Concept

Upload is usually the easy half. If the page has a real file input, set_input_files on that locator puts the file straight in with no operating system dialog involved, and you can pass a list of paths for a multiple input or an empty list to clear it. For a drag and drop area with no input, wrap the click in expect_file_chooser and set the files on the chooser. Download is the mirror image: the file is streamed, so you wrap the click in expect_download, take the download off the value attribute, then save_as it wherever you want. Reading the saved file afterwards is plain Python.

Interview Strategy

Split it into the two halves and name one gotcha each: uploads bypass the operating system dialog entirely, and downloads need the wrapper because the file is streamed. The senior touch is saying you assert on the file contents, not just that a download happened.

How to phrase it

I answer this as two halves, because they feel quite different. Upload first. If the page has a real file input, I do not automate any operating system dialog at all, and that surprises people who have fought with dialogs in other tools. I just call set_input_files on the input locator with a path, and Playwright sets it directly. I can pass a list of paths for a multiple input, or an empty list to clear a selection. If the page is a drag and drop zone with no visible input, I wrap the click in expect_file_chooser and set the files on the chooser instead. Download is the mirror image. The file is streamed, so I have to be listening before I click, which means wrapping the click in expect_download and taking the download object off the value attribute afterwards. Then save_as writes it wherever I want, and suggested_filename tells me what the server proposed. The bit I insist on is going one step further than most tests do. Asserting that a download happened is a weak check. I save the file and then read it with plain Python, check the header row of the CSV and the number of records, because that is what the user actually cares about.

Key points to hit

  • set_input_files on a real file input bypasses the operating system dialog completely.
  • Pass a list of paths for a multiple input, or an empty list to clear it.
  • For a drag and drop area with no input, use expect_file_chooser around the click.
  • Downloads are streamed, so wrap the click in expect_download and read the value attribute.
  • save_as writes the file, suggested_filename tells you what the server proposed.
  • Assert on the file contents with plain Python, not only that a download arrived.
Code
# Upload: no operating system dialog involved
page.get_by_label("Resume").set_input_files("fixtures/resume.pdf")

# Download: listen first, then click
with page.expect_download() as info:
    page.get_by_role("button", name="Export CSV").click()

download = info.value
download.save_as("out/orders.csv")

with open("out/orders.csv") as f:
    assert f.readline().startswith("order_id,")
22

page.pause() and codegen: how do you debug a failing test?

Junior
Concept

page.pause stops the run and opens the Playwright Inspector, where you step through the remaining actions and, more usefully, pick locators off the live page and try them until one matches cleanly. It needs a visible browser, so you run headed, and under pytest you add the -s flag so the output is not captured. Setting PWDEBUG to 1 turns a whole run into a debug session. Codegen works the other way: playwright codegen with a python-pytest target writes a test while you click through the app. Neither replaces the trace viewer, which is how you debug a failure that only happens in CI.

Interview Strategy

Give the three tools their own jobs: pause and the Inspector for a live look, codegen for a first draft and locator ideas, the trace viewer for anything that failed in CI. Mention the -s flag, because a paused test that prints nothing confuses everyone the first time.

How to phrase it

I use three tools and I keep their jobs separate. When a test fails on my machine and I want to look around, I drop page.pause where it breaks. That stops the run and opens the Playwright Inspector, where I can step through the remaining actions one at a time and, more usefully, hover over the live page to pick locators and try them until one matches cleanly. Two practical notes: it needs a visible browser, so I run headed, and under pytest I add the -s flag, because otherwise pytest captures the output and it looks like nothing is happening. Setting PWDEBUG to 1 does the same thing for a whole run. Codegen works the other way round. I run playwright codegen with a python-pytest target, click through the flow in the browser, and it writes the test for me. I never ship that output as it is, because it tends to be a flat script with weak assertions, but it is an excellent first draft and it is genuinely the fastest way to learn what a good locator looks like. And for anything that failed in CI rather than on my machine, neither of these helps, that is exactly what the trace viewer is for.

Key points to hit

  • page.pause stops the run and opens the Inspector to step and try locators on the live page.
  • It needs a headed browser, and under pytest you add -s so the output is not captured.
  • Setting PWDEBUG to 1 turns a whole run into a debug session.
  • playwright codegen with a python-pytest target writes a first draft and teaches good locators.
  • Codegen output needs real assertions before it ships; treat it as a draft.
  • Neither replaces the trace viewer, which is how you debug a CI only failure.
Code
def test_checkout(page):
    page.goto("/cart")
    page.pause()          # opens the Inspector: pytest --headed -s
    page.get_by_role("button", name="Pay").click()

# Record a flow straight into a pytest test
# playwright codegen --target python-pytest https://example.com
23

Retries vs fixing the flake: are retries a real solution?

Senior
Concept

The built in retries option belongs to the Node runner. In Python the runner is pytest, so retries come from pytest-rerunfailures with a reruns count, optionally with a delay between attempts, and you pair it with tracing retained on failure so every failed attempt leaves you a trace to open. Retries are a shock absorber for CI, they stop one infrastructure blip blocking a release that is genuinely fine, and they never repair the test. The honest position is that a rerun which goes green is still logged as a defect, and the real causes are almost always fixed sleeps, shared data, or asserting on a value read once.

Interview Strategy

Correct the flag name for Python early, since that alone shows you have run this stack, then take a clear position: retries protect the pipeline and never repair the test. The senior move is naming the three usual root causes and saying a reran pass is still tracked.

How to phrase it

First a small correction I like to make, because it shows I have actually run this stack. The built in retries option is the Node runner. In Python the runner is pytest, so I get retries from pytest-rerunfailures with a reruns count, and I pair it with tracing set to retain on failure so every failed attempt leaves me a trace I can open. Now my position. Retries belong in CI as a shock absorber, so a single network blip does not block a release that is genuinely fine, and I will defend that to anyone. But a retry never repairs a test, it only hides it, and a suite that quietly passes on the second attempt is a suite nobody trusts within a month. So the rule I work to is that a rerun which goes green is still recorded as a defect, with the trace attached, and it gets fixed. When I look at what caused these, it is almost always one of three things, and most people were never taught any of them. A fixed sleep instead of a real condition. Shared test data, or one login account being used by two workers at the same time. Or asserting on a value read once instead of asserting on the locator. Fix those three and the flake rate drops to almost nothing, and then the retry setting is just insurance.

Key points to hit

  • The built in retries option is the Node runner; in Python use pytest-rerunfailures.
  • Pair reruns with tracing retain-on-failure so every failed attempt leaves a trace.
  • Retries protect the pipeline from a blip, they never repair the test.
  • A rerun that passes is still logged as a defect with its trace, and it gets fixed.
  • The usual causes: fixed sleeps, shared data or accounts, and asserting on a once read value.
Code
# CI: absorb a blip, keep the evidence
# pytest -n 4 --reruns 1 --reruns-delay 2 --tracing retain-on-failure

# The cause, most of the time
# time.sleep(2)
# assert page.locator("#total").inner_text() == "4000"

# The fix
expect(page.get_by_test_id("total")).to_have_text("4000")
24

Playwright Python vs Playwright TypeScript: does the language change anything?

Mid
Concept

The engine and the library are the same, so what you can do is the same, and the method names simply follow each language's style. What differs is the runner. TypeScript uses Playwright's own test runner with a config file, and retries, sharding, projects, UI mode and merged reports come built in. Python uses pytest, so you get fixtures, parametrize, markers and the whole pytest plugin world, and you add pytest-xdist for parallel runs and pytest-rerunfailures for retries. A few runner level features are Node only today, UI mode and built in sharding among them, while the library features are identical.

Interview Strategy

Separate library from runner, because that is the whole answer: the library is the same and the runner is not. Naming a feature that is genuinely Node only, UI mode for instance, is what makes the answer credible rather than merely diplomatic.

How to phrase it

I split this into the library and the runner, because that is where the real answer sits. The library is the same. Locators, auto waiting, tracing, network routing, contexts, all identical, and the method names simply follow each language's style, so get_by_role in Python is getByRole in TypeScript. What differs is the runner. In TypeScript, Playwright ships its own test runner with a config file, and retries, sharding, projects, UI mode and merged reports come built in. In Python the runner is pytest, so I get fixtures, parametrize, markers and the entire pytest plugin ecosystem, and I add pytest-xdist for parallel runs and pytest-rerunfailures for retries. I am honest that a few runner level features are Node only today, UI mode and built in sharding are the two I name, and the Node runner tends to get new runner features first. So I choose by team rather than by preference. If the developers write TypeScript and the app is TypeScript, tests in TypeScript get read and even contributed to. If the team already lives in Python, or the data and API tooling around the tests is Python, then Playwright Python with pytest is the better fit and loses nothing that matters.

Key points to hit

  • Same engine and same library: locators, auto waiting, tracing and routing are identical.
  • Method names follow each language, get_by_role against getByRole.
  • TypeScript uses Playwright's own runner, with retries, sharding and projects built in.
  • Python uses pytest, so fixtures, parametrize and markers, plus xdist and rerunfailures.
  • UI mode and built in sharding are Node only today.
  • Choose by the team's language so developers can read and contribute to the tests.
Code
# Python, pytest runner
def test_pay(page):
    page.goto("/cart")
    page.get_by_role("button", name="Pay").click()

# TypeScript, Playwright runner (for contrast)
# test("pay", async ({ page }) => {
#   await page.goto("/cart");
#   await page.getByRole("button", { name: "Pay" }).click();
# });
25

pytest parametrize vs a loop inside one test: how do you data drive?

Mid
Concept

A loop inside one test runs every case but reports as one test. The first failure ends the loop, so you never learn whether case four also breaks, and the message says the test failed rather than which row failed. parametrize turns each row into its own test: collected separately, run independently, rerun or skipped individually, and spread across pytest-xdist workers because they really are separate tests. You can give rows readable ids, stack decorators for a matrix, and lift the data into a fixture or a JSON file once several tests need it.

Interview Strategy

Make it about reporting and isolation rather than syntax: one test hides which row failed, parametrize gives every row its own result. Mentioning that parametrized cases distribute across xdist workers is the part that shows you have run this at scale.

How to phrase it

The difference is reporting and isolation, not syntax. If I loop over my data inside one test, pytest sees one test. So when row three fails, the loop stops there, I never find out whether rows four and five were also broken, and the failure just says the test failed rather than telling me which input did it. parametrize turns each row into a separate test. Each one is collected with its own name, runs on its own and fails on its own, so I can see straight away that two of six inputs are wrong and exactly which two. That also means I can rerun just the failing case, or mark one row as expected to fail with a ticket number, instead of commenting out the whole test and losing the other five. And at scale it matters more than people expect, because parametrized cases are genuinely separate tests, so pytest-xdist can spread them across workers, whereas a loop is stuck inside one test on one worker. Two habits I add: I give rows readable ids so the report says invalid_email rather than a number, and once several tests need the same data I lift it into a fixture or a JSON file so the data and the test are not tangled together.

Key points to hit

  • A loop is one test: the first failure stops it and the report never names the row.
  • parametrize makes each row its own test, collected, run and reported separately.
  • You can rerun or mark a single row without touching the rest.
  • Parametrized cases spread across pytest-xdist workers; a loop stays on one worker.
  • Give rows readable ids so the report names the case, not a number.
  • Lift shared data into a fixture or a JSON file once several tests need it.
Code
import pytest

@pytest.mark.parametrize(
    "email,message",
    [
        ("", "Email is required"),
        ("sree@", "Enter a valid email"),
    ],
    ids=["empty", "invalid"],
)
def test_email_validation(page, email, message):
    page.goto("/login")
    page.get_by_label("Email").fill(email)
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_text(message)).to_be_visible()

Want more free SDET prep?

Get my free interview resources and new Q&A drops straight to your inbox.

300+ questions in the full kit

SDET Playwright Python Foundation Kit 2026

A glimpse of the full Foundation Kit: 311 questions across 33 sections, Python for SDETs, locators, auto waiting, fixtures, network mocking, API testing, CI/CD and AI with MCP, each section closing with its own quiz.

Other stacks