Pages and frames
Work with tabs, popups, iframes, and page-specific assertions without losing test ownership.
A Page is one browser tab. Most tests should use one page, because one page keeps the story easy to follow: navigate, act, assert. Real products still open popups, embed iframes, redirect through external providers, or keep an admin page beside a user page.
The goal is not to avoid multiple pages and frames. The goal is to make ownership visible. Every action and assertion should make it obvious which tab or frame the user is in.
Start with one page
Start every flow with the simplest shape:
$page = $context->newPage();
$page->goto('https://app.example.test');
Add another page only when the product creates another tab or when the test genuinely needs two visible surfaces at the same time.
Good reasons for multiple pages:
- a link opens a popup;
- an OAuth or payment provider opens a separate page;
- an admin approves something while a user page waits;
- a test compares behavior between two tabs in the same session.
Bad reasons:
- trying to isolate users in tabs instead of contexts;
- hiding a long scenario by spreading it over variables;
- working around a missing wait or assertion.
Name pages by role
The first readability rule is simple: do not call everything $page.
$adminPage = $adminContext->newPage();
$customerPage = $customerContext->newPage();
$adminPage->goto('https://app.example.test/admin/orders');
$customerPage->goto('https://app.example.test/orders');
When a failure says expect($customerPage...), the reader already knows which side of the product failed. Names such as $page2, $newPage, or $otherPage force the reader to reconstruct the flow.
Keep assertions local to the page
In a multi-page flow, the action and result can happen in different places. Write that boundary explicitly:
use function Playwright\Testing\expect;
$adminPage->getByRole('button', ['name' => 'Approve'])->click();
expect($customerPage->getByText('Approved'))->toBeVisible();
This is better than asserting on whichever page variable happens to be in scope. A browser test fails at runtime; readable page ownership is part of the debugging surface.
This also prevents false confidence. An admin page may show "Approved" because the admin action succeeded, while the customer page still has stale data. Assert on the page that represents the user promise.
Work inside iframes with frame locators
An iframe has its own document. A locator on the main page does not act inside it unless you cross the frame boundary.
Use frameLocator() and then continue with normal locator habits:
$paymentFrame = $page->frameLocator('iframe[name="payment"]');
$paymentFrame->getByLabel('Card number')->fill('4242424242424242');
$paymentFrame->getByLabel('Expiry')->fill('12/30');
$paymentFrame->getByRole('button', ['name' => 'Pay'])->click();
This says exactly what is happening: the payment controls live inside the payment frame.
Tip Treat a frame as a boundary in the product. If the frame belongs to an external provider, assert only the behavior your product can depend on. Do not overfit to private provider markup.
Prefer frame boundaries over deep selectors
Avoid writing selectors that pretend the page and iframe are one document:
$page->locator('iframe[name="payment"] input[name="card"]')->fill('4242');
That selector hides two problems. It mixes documents, and it depends on provider markup. The failure will usually be less useful than the cause.
Prefer:
$page
->frameLocator('iframe[name="payment"]')
->getByLabel('Card number')
->fill('4242424242424242');
If the iframe itself is late to load, assert the frame owner or the product state that displays it. Do not add a blind sleep.
Handle popups without racing the event
Some actions create a new page. Register the wait around the action so the popup cannot be missed:
$popup = $page->waitForPopup(function () use ($page): void {
$page->getByRole('link', ['name' => 'Open invoice'])->click();
}, ['timeout' => 5000]);
$popup->waitForLoadState();
expect($popup->getByRole('heading', ['name' => 'Invoice']))->toBeVisible();
The important rule is sequencing: the wait must be active before the click runs. If the test clicks first and waits later, a fast popup can appear before the listener exists.
Use the context-level popup wait when the action is not tied cleanly to a single page, but prefer the page-level form when one page clearly owns the action.
Navigate with intent
goto() starts a page at a URL. User actions then drive the journey. After an action, assert the page state that matters:
$page->getByRole('link', ['name' => 'Settings'])->click();
expect($page)->toHaveURL('https://app.example.test/settings');
expect($page->getByRole('heading', ['name' => 'Settings']))->toBeVisible();
For pages that keep the same URL, assert a heading, landmark, tab state, form field, or visible result instead. URL assertions are useful, but they are not the only proof of navigation.
Single-page applications make this especially important. The browser may not perform a full document navigation when the screen changes. In that case, the correct readiness signal is often the visible route content, not a load event.
Frames and popups have different failure modes
Frames usually fail because the frame did not load, the selector crosses the boundary incorrectly, or the external provider changed its markup.
Popups usually fail because the wait was registered too late, the browser blocked the popup, or the action conditionally opens a new page.
The debugging evidence is different:
- for frames, capture a screenshot and inspect whether the iframe exists;
- for popups, log whether the action ran and whether the popup page was created;
- for both, add a targeted assertion before the failing action instead of increasing global timeouts.
Safe default
Use this policy unless the product proves otherwise:
- One context per user.
- One primary page per flow.
- Named variables for every additional page.
frameLocator()for iframe content.waitForPopup()wrapped around the action that opens a popup.- Assertions on the page or frame where the user sees the result.
This policy keeps browser tests readable when they fail, which is when readability matters most.
Verification checklist
- Does the test have one primary page unless the product requires more?
- Are extra pages named by role?
- Does every iframe interaction use
frameLocator()? - Is the popup wait registered around the action?
- Does each assertion target the right page or frame?
- Does the failure message point to the user-visible state, not just the click?
Go next
- Previous guide: Browsers and contexts
- Next guide: Navigation and waiting
- Subject map: Pages, Frames
- Runtime events: Page and browser events
- Copy a popup task: Handle a popup window
- API reference: Page, FrameLocator