Page navigation means moving the browser from one URL or application state to another.
Direct URL
Open a page directly using page.goto().
Link Navigation
Click a link or button and allow the application to navigate.
Application Redirect
Login, checkout, payment, and other workflows may redirect automatically.
Playwright provides navigation APIs and automatic synchronization around browser actions. The test can perform an action and then validate the resulting application state.
The simplest way to navigate to a URL is page.goto().
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")
print(page.url)
browser.close()
Most real applications navigate because the user clicks a link, button, menu item, or other interactive element.
page.get_by_role("link", name="Products").click()
print(page.url)
Prefer semantic locators such as:
get_by_role()get_by_label()get_by_text()get_by_test_id()
Playwright also supports browser-style navigation operations.
page.goto("https://example.com/products")
page.go_back()
page.go_forward()
page.reload()
print(page.url)
go_back()
Moves the page to the previous history entry.
go_forward()
Moves to the next browser history entry.
reload()
Reloads the current page.
After navigation, the test should validate the resulting state. Playwright's web-first assertions automatically wait and retry until the expected condition is satisfied or the assertion times out.
Example: Validate URL After Login
from playwright.sync_api import expect
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("secret")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url(r".*/dashboard")
❌ Avoid
# Read the URL immediately
assert "/dashboard" in page.url
This can make synchronization the test's responsibility. If navigation is still in progress, the check may happen before the expected URL is available.
✅ Preferred
from playwright.sync_api import expect
expect(page).to_have_url(r".*/dashboard")
Playwright waits and retries the assertion until the URL matches or the assertion timeout is reached.
Validate Page Content After Navigation
❌ Avoid
assert page.locator("h1").is_visible()
✅ Preferred
expect(
page.get_by_role("heading", name="Dashboard")
).to_be_visible()
Consider an enterprise application where a successful login redirects the user from:
from playwright.sync_api import expect
def test_successful_login(page):
page.goto("https://example.test/login")
page.get_by_label("Email").fill("test@example.com")
page.get_by_label("Password").fill("secret")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url(r".*/dashboard")
expect(
page.get_by_role("heading", name="Dashboard")
).to_be_visible()
Notice that there is no fixed sleep() between clicking
Sign in and validating the dashboard.
Modern applications often open reports, payment pages, documentation, or external systems in a new tab.
with page.expect_popup() as popup_info:
page.get_by_role("link", name="Open Report").click()
report_page = popup_info.value
expect(report_page).to_have_url(r".*/report")
Imagine an admin application where clicking Open Customer Report opens a report in another tab.
from playwright.sync_api import expect
with page.expect_popup() as popup_info:
page.get_by_role(
"link",
name="Open Customer Report"
).click()
report_page = popup_info.value
expect(report_page).to_have_title(
"Customer Report"
)
expect(
report_page.get_by_role(
"heading",
name="Customer Report"
)
).to_be_visible()
Selenium and Playwright both automate browser navigation. The main difference is how much synchronization the test author typically has to manage.
| Area | Playwright | Selenium |
|---|---|---|
| Direct navigation | page.goto() |
driver.get() |
| Element interaction | Locators include automatic waiting behavior | Explicit synchronization is often used depending on the scenario |
| Assertions | Web-first assertions such as expect() |
Assertions depend on the test framework and synchronization approach |
| New tabs | expect_popup() |
Window/tab handles are commonly managed explicitly |
| Browser isolation | BrowserContext provides isolated sessions | Isolation is generally managed through driver/session setup |
| Synchronization style | Strong web-first synchronization model | Flexible model with explicit waits available |
from selenium import webdriver
driver = webdriver.Chrome()
driver.get("https://example.test/login")
driver.find_element(
"id",
"email"
).send_keys("test@example.com")
driver.find_element(
"id",
"password"
).send_keys("secret")
driver.find_element(
"id",
"login"
).click()
print(driver.current_url)
driver.quit()
from playwright.sync_api import expect
page.goto("https://example.test/login")
page.get_by_label("Email").fill("test@example.com")
page.get_by_label("Password").fill("secret")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url(r".*/dashboard")
expect(
page.get_by_role("heading", name="Dashboard")
).to_be_visible()
The key benefit is not simply fewer lines of code. The important difference is the synchronization model used around browser actions and assertions.
Automatic Waiting
Playwright's locator actions wait for the required conditions before interacting with elements.
Web-First Assertions
Assertions such as to_have_url() and
to_be_visible() retry until the condition is met
or the timeout is reached.
Modern Page Handling
Multiple pages, tabs, contexts, and popup events are modeled directly by the Playwright API.
One of the most important navigation practices is avoiding fixed
delays such as time.sleep() or
page.wait_for_timeout() for normal synchronization.
❌ Avoid Hard Waits
import time
page.get_by_role("button", name="Sign in").click()
time.sleep(5)
assert "/dashboard" in page.url
This waits five seconds regardless of whether the application is ready after one second or still needs more than five seconds.
page.wait_for_timeout(5000)
wait_for_timeout() can be useful while debugging,
but fixed delays should not be the normal synchronization
strategy for production tests.
✅ Preferred — Wait for Real State
from playwright.sync_api import expect
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url(r".*/dashboard")
The assertion waits for the expected URL instead of waiting for an arbitrary number of seconds.
expect(
page.get_by_role("heading", name="Dashboard")
).to_be_visible()
This validates an observable application state.
Another Example — Wait for a Specific Response
When a particular network response is the meaningful synchronization point, wait for that response rather than using a fixed delay.
❌ Avoid
page.get_by_role(
"button",
name="Save"
).click()
page.wait_for_timeout(3000)
✅ Preferred
with page.expect_response(
lambda response:
"/api/customer" in response.url
and response.request.method == "POST"
) as response_info:
page.get_by_role(
"button",
name="Save"
).click()
response = response_info.value
assert response.ok
Modern applications such as React, Angular, and Vue applications may change the URL or screen without performing a traditional full page reload.
page.get_by_role("link", name="Orders").click()
expect(page).to_have_url(r".*/orders")
expect(
page.get_by_role("heading", name="Orders")
).to_be_visible()
Consider an e-commerce flow:
from playwright.sync_api import expect
def test_customer_can_reach_checkout(page):
page.goto("https://shop.example.test")
page.get_by_role(
"link",
name="Products"
).click()
expect(page).to_have_url(r".*/products")
page.get_by_role(
"link",
name="Laptop Pro"
).click()
expect(page).to_have_url(r".*/products/.*")
page.get_by_role(
"button",
name="Add to cart"
).click()
page.get_by_role(
"link",
name="Cart"
).click()
expect(page).to_have_url(r".*/cart")
page.get_by_role(
"button",
name="Checkout"
).click()
expect(page).to_have_url(r".*/checkout")
expect(
page.get_by_role(
"heading",
name="Checkout"
)
).to_be_visible()
Playwright BrowserContexts allow independent browser sessions. This is useful when different users need to navigate through the same application independently.
user1_context = browser.new_context()
user2_context = browser.new_context()
user1_page = user1_context.new_page()
user2_page = user2_context.new_page()
user1_page.goto("https://example.test/dashboard")
user2_page.goto("https://example.test/dashboard")
Each context has its own cookies, local storage, session state, and pages.
A business application may require navigation through different roles or sessions.
admin_context = browser.new_context()
customer_context = browser.new_context()
admin_page = admin_context.new_page()
customer_page = customer_context.new_page()
admin_page.goto("https://example.test/admin")
# Admin performs approval...
customer_page.goto("https://example.test/login")
# Customer performs independent login...
Playwright and Selenium solve browser automation problems using different APIs and synchronization approaches.
| Consideration | Playwright | Selenium |
|---|---|---|
| Modern web applications | Strong support for modern browser automation patterns | Also widely used for modern browser automation |
| Existing Selenium framework | Migration requires engineering effort | Existing investment can be retained |
| Synchronization | Web-first synchronization is a central design feature | Explicit waits provide flexible synchronization |
| Team experience | Requires Playwright knowledge | Existing Selenium expertise may reduce adoption effort |
| Architecture | BrowserContext, Page, Locator model | WebDriver-based architecture |
| Capability | Practical Benefit |
|---|---|
| Web-first assertions | Tests can wait for expected application state. |
| Automatic actionability checks | Reduces the need for manual synchronization before many interactions. |
| BrowserContext | Supports isolated browser sessions. |
| Popup handling | Provides a direct event-based API for new pages. |
| Modern locators | Encourages semantic and user-facing element selection. |
| Navigation assertions | URL and title changes can be validated with retrying assertions. |
- Use
page.goto()for direct navigation. - Use semantic locators for link and button navigation.
- Validate navigation using web-first assertions.
- Avoid
time.sleep()for normal synchronization. - Avoid fixed
page.wait_for_timeout()delays in production tests. - Wait for observable application conditions.
- Use
page.expect_popup()for popup/new-tab workflows. - Use BrowserContexts when independent sessions are required.
- Keep navigation assertions close to the action that causes navigation.
- Use meaningful business-level assertions after navigation.
- Do not depend on arbitrary timing assumptions.
- Keep tests independent and deterministic.
from playwright.sync_api import Page, expect
def test_customer_checkout_navigation(page: Page):
# Arrange
page.goto("https://shop.example.test/login")
page.get_by_label("Email").fill("customer@example.com")
page.get_by_label("Password").fill("secret")
# Act
page.get_by_role(
"button",
name="Sign in"
).click()
# Assert
expect(page).to_have_url(r".*/dashboard")
# Navigate to products
page.get_by_role(
"link",
name="Products"
).click()
expect(page).to_have_url(r".*/products")
# Open product
page.get_by_role(
"link",
name="Laptop Pro"
).click()
expect(page).to_have_url(r".*/products/.*")
# Add product
page.get_by_role(
"button",
name="Add to cart"
).click()
# Open cart
page.get_by_role(
"link",
name="Cart"
).click()
expect(page).to_have_url(r".*/cart")
# Checkout
page.get_by_role(
"button",
name="Checkout"
).click()
expect(page).to_have_url(r".*/checkout")
expect(
page.get_by_role(
"heading",
name="Checkout"
)
).to_be_visible()
- Navigate using Playwright APIs or real user actions.
- Use semantic locators.
- Validate the resulting URL where URL state matters.
- Validate important page content after navigation.
- Prefer web-first assertions.
- Avoid arbitrary sleeps.
- Avoid fixed timeout-based synchronization.
- Use popup events for new tabs.
- Use BrowserContext for independent sessions.
- Keep tests deterministic.
Click → Sleep → Hope → Assert
It is:
Action → Wait for meaningful state → Assert → Continue
This approach makes navigation tests easier to understand and reduces synchronization code that is based only on elapsed time.