Resource library

QA How-To

Playwright expect toHaveURL: Examples and Best Practices

Use playwright expect toHaveURL examples for login redirects, generated IDs, filters, hashes, browser history, canonical routes, popups, and secure navigation.

24 min read | 2,571 words

TL;DR

Strong playwright expect toHaveURL examples use exact strings for fixed routes, anchored patterns for generated paths, predicates for query and hash rules, and the captured popup Page for new tabs. Pair every important route with destination evidence and keep sensitive URL data out of diagnostics.

Key Takeaways

  • Select exact URL components based on the navigation risk instead of matching one route keyword.
  • Use predicates for return paths, filters, repeated parameters, fragments, and canonicalization rules.
  • Constrain generated IDs with anchored path patterns before extracting them for later steps.
  • Verify route guards with a positive safe destination and explanatory UI state.
  • Test back, forward, deep-link, and popup behavior on the Page object that actually navigates.
  • Pair route evidence with a unique heading, panel, record, or error message.
  • Make authentication, tenant, locale, and feature-flag preconditions explicit for isolated navigation tests.

These playwright expect toHaveURL examples cover the navigation cases that usually break real test suites: relative destinations, login return paths, generated resource IDs, search parameters, hash-based tabs, redirects, browser history, and popups. Each example uses a page assertion that retries while the browser settles on the expected address.

Good URL tests do not simply search the current string for one familiar word. They choose the URL components that represent the user journey, allow only intentional variation, and pair navigation evidence with destination content. The recipes below are current for Playwright Test in 2026 and include runnable in-memory patterns where practical.

TL;DR

Navigation risk Best expectation
Fixed application route Relative string with configured baseURL
Generated path segment Anchored regular expression or predicate
Filters in any parameter order Predicate using URL.searchParams
Structured route family URLPattern
Link opens another tab Capture popup, then assert the popup Page
Client-side tab or anchor Assert pathname plus hash

Always await expect(page).toHaveURL(...). Then assert a unique element on the destination so a correct-looking address cannot hide an error view.

1. A self-contained exact URL example

This complete spec intercepts an invented host and fulfills pages from memory. It is runnable without a web server and demonstrates exact matching after a user action.

import { test, expect } from '@playwright/test';

test('opens the team directory', async ({ page }) => {
  await page.route('https://portal.test/**', async route => {
    const { pathname } = new URL(route.request().url());
    const body = pathname === '/'
      ? '<a href="/team">Team directory</a>'
      : '<h1>Team directory</h1>';

    await route.fulfill({ status: 200, contentType: 'text/html', body });
  });

  await page.goto('https://portal.test/');
  await page.getByRole('link', { name: 'Team directory' }).click();

  await expect(page).toHaveURL('https://portal.test/team');
  await expect(page.getByRole('heading', { name: 'Team directory' }))
    .toBeVisible();
});

The URL check establishes where the browser landed, while the heading establishes which view rendered. If the route returned a branded error page at /team, the second assertion would catch it.

In a normal project, use baseURL and await expect(page).toHaveURL('/team') so the same test runs against local and preview environments. Keep the full-host form for origin routing, tenant domains, and external handoffs where the host is part of the requirement.

2. Login redirect and return-path example

A protected route should send an anonymous user to login without losing the requested destination. A predicate checks decoded query values without depending on raw parameter order.

test('preserves the protected destination', async ({ page }) => {
  await page.goto('/reports/quarterly');

  await expect(page).toHaveURL(url => {
    return url.pathname === '/login'
      && url.searchParams.get('returnTo') === '/reports/quarterly';
  });

  await expect(page.getByRole('heading', { name: 'Sign in' }))
    .toBeVisible();
});

After authentication, assert the return journey separately:

await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page).toHaveURL('/reports/quarterly');
await expect(page.getByRole('heading', { name: 'Quarterly report' }))
  .toBeVisible();

Keep credentials in environment configuration, never in the spec. Avoid asserting third-party identity-provider parameters that your product does not own. They can change without breaking the user contract and may contain sensitive values. The Playwright authentication best practices guide explains reusable storage state and account isolation.

3. Generated-ID playwright expect toHaveURL examples

Creation flows often navigate to a path containing a server-generated ID. Constrain its format rather than accepting any suffix.

await page.getByRole('button', { name: 'Place order' }).click();

await expect(page).toHaveURL(/\/orders\/ORD-\d{6}$/);
await expect(page.getByRole('heading', { name: 'Order confirmed' }))
  .toBeVisible();

An anchored expression prevents /orders/ORD-123456/edit or /error?next=/orders/ORD-123456 from passing. If environment hosts vary, matching a constrained path suffix is more portable than embedding the full origin.

When later steps need the generated ID, first let the page assertion establish the final route, then parse the settled URL:

await expect(page).toHaveURL(url =>
  /^\/orders\/ORD-\d{6}$/.test(url.pathname),
);

const orderId = new URL(page.url()).pathname.split('/').at(-1);
expect(orderId).toMatch(/^ORD-\d{6}$/);

This snapshot extraction is safe because the prior assertion established the route. Do not read page.url() immediately after the click and hope that a generic regex matcher will retry. If the ID is returned by an intercepted API response, capture it there only when the test also needs to verify consistency between API and UI identifiers.

4. Search and filter query examples

Query strings are commonly serialized in a different order from test expectations. Use searchParams when order is irrelevant.

await page.getByRole('searchbox', { name: 'Search jobs' })
  .fill('automation engineer');
await page.getByLabel('Location').selectOption('remote');
await page.getByRole('button', { name: 'Search' }).click();

await expect(page).toHaveURL(url => {
  return url.pathname === '/jobs'
    && url.searchParams.get('q') === 'automation engineer'
    && url.searchParams.get('location') === 'remote';
});

This checks decoded values, so the test does not care whether the browser encoded the space as + or %20. Pair it with result evidence:

await expect(page.getByRole('heading', { name: /automation engineer/i }))
  .toBeVisible();
await expect(page.getByTestId('active-location-filter'))
  .toHaveText('Remote');

For repeated filters, use getAll and make the ordering rule explicit:

await expect(page).toHaveURL(url => {
  const levels = url.searchParams.getAll('level');
  return url.pathname === '/jobs'
    && levels.length === 2
    && levels.includes('senior')
    && levels.includes('lead');
});

Do not require extra tracking parameters unless they are a tested analytics contract. Conversely, do not ignore a required tenant, locale, or permission scope parameter merely to simplify the expectation.

Pagination deserves an explicit rule. If clicking Next changes page=2, assert that parameter and the first result expected on page two. If selecting a new filter should reset pagination, verify that page is removed or becomes 1. Merely checking for the filter parameter would miss a common bug where users remain on an empty later page after narrowing results.

When a query encodes boolean or numeric values, remember that URLSearchParams returns strings. Compare pageSize with '25', or parse it and validate a numeric relationship after checking for null. Do not rely on JavaScript coercion inside the predicate, since values such as an empty string can produce surprising results and weaken the route contract.

5. URLPattern example for route families

URLPattern is useful when the route has named dynamic segments and several URL components matter.

const projectSettings = new URLPattern({
  protocol: 'https',
  hostname: ':tenant.example.com',
  pathname: '/projects/:projectId/settings',
});

await expect(page).toHaveURL(projectSettings);

The pattern states that an HTTPS tenant subdomain and project settings path are required, while tenant and project IDs vary. If only the pathname matters, omit the origin components:

await expect(page).toHaveURL(new URLPattern({
  pathname: '/knowledge-base/*',
}));

Use this form only when the runtime used by the Playwright project provides URLPattern or the project intentionally supplies a compatible polyfill. A predicate is a clear fallback:

await expect(page).toHaveURL(url =>
  url.protocol === 'https:'
  && url.hostname.endsWith('.example.com')
  && /^\/projects\/[^/]+\/settings$/.test(url.pathname),
);

Be careful with endsWith('.example.com'). It permits subdomains but not the apex host, which may be exactly right for tenant routing. State the allowed host rule explicitly instead of using a loose substring that could accept example.com.attacker.test.

6. Multi-step checkout route examples

A multi-step flow should verify each stable navigation boundary that controls what the user can do next.

await page.goto('/checkout/cart');
await page.getByRole('button', { name: 'Continue to delivery' }).click();
await expect(page).toHaveURL('/checkout/delivery');
await expect(page.getByRole('heading', { name: 'Delivery' })).toBeVisible();

await page.getByLabel('Standard delivery').check();
await page.getByRole('button', { name: 'Continue to payment' }).click();
await expect(page).toHaveURL('/checkout/payment');
await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();

These assertions prove progression and make the failing stage obvious. Do not assert every internal redirect or loading route. Focus on user-stable steps that correspond to business state.

Also test the guard behavior directly. Opening /checkout/payment without delivery data might redirect to /checkout/delivery:

await page.goto('/checkout/payment');
await expect(page).toHaveURL('/checkout/delivery');
await expect(page.getByRole('alert'))
  .toHaveText('Choose a delivery method before payment');

The message explains why the redirect occurred. The URL alone cannot distinguish a correct guard from an unrelated routing defect. Keep each test's setup explicit so stored state from another test does not bypass the guard.

7. Hash routes, tabs, and deep links

Fragments can encode an anchor, tab, or client-side route without contacting the server. Assert them when deep linking and browser history are requirements.

await page.goto('/settings');
await page.getByRole('tab', { name: 'Notifications' }).click();

await expect(page).toHaveURL(url =>
  url.pathname === '/settings' && url.hash === '#notifications',
);
await expect(page.getByRole('tabpanel', { name: 'Notifications' }))
  .toBeVisible();

This checks both shareable address state and rendered tab state. If the application intentionally keeps tabs out of the URL, only the panel assertion belongs in the test.

Test a copied deep link in a fresh context or isolated page:

await page.goto('/settings#notifications');
await expect(page).toHaveURL('/settings#notifications');
await expect(page.getByRole('tab', { name: 'Notifications' }))
  .toHaveAttribute('aria-selected', 'true');

A deep-link test catches applications that update the hash on click but fail to restore state on direct load. It also validates accessibility state rather than relying on CSS selection alone.

8. Back, forward, and client-side routing examples

Browser history is a user-visible contract in dashboards and single-page applications. Assert both address and view after moving through history.

await page.goto('/dashboard');
await page.getByRole('link', { name: 'Projects' }).click();
await expect(page).toHaveURL('/projects');

await page.getByRole('link', { name: 'Project Atlas' }).click();
await expect(page).toHaveURL('/projects/atlas');

await page.goBack();
await expect(page).toHaveURL('/projects');
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible();

await page.goForward();
await expect(page).toHaveURL('/projects/atlas');
await expect(page.getByRole('heading', { name: 'Project Atlas' })).toBeVisible();

goBack() and goForward() can return null for same-document history transitions, so do not make the response object the main assertion for SPA behavior. The URL plus rendered view describes what users observe.

If filters should survive back navigation, include their query parameters and active controls in the expected state. Avoid waitForLoadState('networkidle') as a generic history wait. Client-side apps can keep connections open, and the user contract is the restored route and content, not zero network activity.

9. Redirect and canonicalization examples

Canonical redirects normalize old paths, casing, locale, or trailing slashes. Assert the canonical destination and its content.

await page.goto('/Docs/Getting-Started/');

await expect(page).toHaveURL('/docs/getting-started');
await expect(page.getByRole('heading', { name: 'Getting started' }))
  .toBeVisible();

Do not enable ignoreCase in this test, because canonical lowercase routing is precisely the requirement. In another product where casing is deliberately equivalent, the option may be appropriate.

For locale routing:

await page.goto('/pricing');
await expect(page).toHaveURL(url =>
  url.pathname === '/en-IN/pricing'
  && url.origin === 'https://www.example.com',
);

Origin checks matter when redirects can cross services. Use an allowlist rule, never hostname.includes('example.com'), which can accept attacker-controlled domains. A redirect test should not include real tokens or sensitive callback values in logs. The Playwright security testing guide offers related origin and session checks.

Canonicalization can also preserve or discard query parameters. State that behavior directly. A redirect from an old product path might need to keep campaign but remove an obsolete legacyId. Use a predicate to check the final canonical path, the retained value, and absence of the retired key. This catches redirects that reach the right page while silently losing user or analytics context.

For permanent redirect status codes, a browser UI test primarily observes the final route. If the precise 301, 302, 307, or 308 response is part of the contract, cover it at the HTTP or API layer as well. Browser history and method-preservation behavior can then receive a focused end-to-end scenario rather than overloading one URL assertion.

10. Popup and new-tab examples

When a link opens another tab, the original page's URL stays unchanged. Capture the popup before the action and assert the returned Page.

test('opens an invoice in a new tab', async ({ page }) => {
  await page.goto('/orders/ORD-100001');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'View invoice' }).click();
  const invoicePage = await popupPromise;

  await expect(invoicePage).toHaveURL(/\/invoices\/INV-\d{6}$/);
  await expect(invoicePage.getByRole('heading', { name: 'Invoice' }))
    .toBeVisible();
});

Starting the event promise before the click prevents missing a fast popup. The regex constrains the destination path, while the heading proves the correct document rendered.

For an external help center, assert the approved origin and path through a predicate. Avoid making the test depend on third-party page content unless that external system is deliberately in scope:

await expect(invoicePage).toHaveURL(url =>
  url.origin === 'https://help.example.com'
  && url.pathname === '/billing/invoices',
);

Close popups when a long scenario opens several, although Playwright's test context cleanup will close them at the end. Explicit cleanup can keep resource use and later page selection clear.

11. Negative and error-route examples

Negative assertions should describe the safe alternative, not merely say where the page is not.

Weak:

await expect(page).not.toHaveURL(/admin/);

This can pass on a blank page, login page, generic error, or any unrelated destination. Stronger:

await page.goto('/admin/users');

await expect(page).toHaveURL(url =>
  url.pathname === '/access-denied'
  && url.searchParams.get('resource') === 'admin-users',
);
await expect(page.getByRole('heading', { name: 'Access denied' }))
  .toBeVisible();

The positive safe state proves the authorization guard's intended behavior. For a deleted resource, assert the stable not-found route or unchanged resource path according to product design, plus the correct 404 content.

Use not.toHaveURL for a bounded transition when the requirement truly is departure from a known temporary route. Even then, add the allowed final route. Broad negative checks are prone to passing for unexpected failures and give poor diagnostic guidance.

12. Reviewing playwright expect toHaveURL examples in a framework

Page objects can own the interaction, while tests own expected navigation outcomes:

import type { Page } from '@playwright/test';

export class ProjectsPage {
  constructor(private readonly page: Page) {}

  async openProject(name: string): Promise<void> {
    await this.page.getByRole('link', { name, exact: true }).click();
  }
}
const projects = new ProjectsPage(page);
await projects.openProject('Project Atlas');

await expect(page).toHaveURL('/projects/atlas');
await expect(page.getByRole('heading', { name: 'Project Atlas' }))
  .toBeVisible();

During review, ask which components must be exact: protocol, host, port, path, query, and fragment. Ask what variation is allowed and why. Confirm that the assertion targets the correct Page, especially after popups. Finally, verify destination content and ensure diagnostics do not expose sensitive URLs.

Build a route test matrix from user journeys rather than every URL in the application. High-value rows include anonymous access to protected content, authorized deep links, expired resources, canonical old links, generated records, locale transitions, and back-button restoration. Each row should name the action, starting state, expected URL components, and unique destination evidence. This keeps route coverage intentional and prevents dozens of shallow substring checks.

Review question Strong evidence Warning sign
Which page should navigate? Named opener or popup variable Generic page reused across tabs
Which components matter? Explicit origin, path, query, or hash rules One keyword regex
What variation is valid? Constrained ID or decoded parameters .* around the whole route
Is the destination usable? Unique heading, panel, or record URL assertion alone
Is data protected? Redacted diagnostics and test credentials Full callback URL in logs

Test isolation is critical. Authentication cookies, tenant selection, locale, and feature flags can all change redirects. Start guard tests in a fresh context with a deliberately known storage state. If a route example passes only after another test runs, investigate shared browser or server state rather than adding retries.

For route constants, prefer one source of stable application paths but keep dynamic expectations near the scenario. A constant for /settings/billing improves rename safety. A global regex called ANY_PROJECT_ROUTE can hide which project ID format, tenant host, or suffix the test should permit. Reuse should sharpen contracts, not generalize them beyond the requirement.

Avoid a helper that accepts a substring and wraps it in new RegExp(value). It removes anchors, escaping, and component semantics at the point where reviewers most need them. A route constant or typed predicate factory can be useful, but its API should reveal all allowances.

When failures are intermittent, record whether the page remained at the source, reached an intermediate route, or overshot the expected route. Those three shapes suggest different causes: the action did not trigger, a redirect or route guard stalled, or the assertion described a transient state rather than the stable destination. A trace provides this timeline more reliably than a final screenshot.

Reproduce with the same worker storage state and feature flags. A manual browser session may already be authenticated or assigned to a different experiment, producing a route that the isolated test never sees. Reliable URL examples make these preconditions explicit in fixtures and project configuration.

For failures, inspect the current URL as parsed components and use Trace Viewer to follow click, navigation, and redirect events. Read the Playwright Trace Viewer guide for a structured workflow.

Interview Questions and Answers

Q: Why should a URL assertion be paired with a content assertion?

A matching address does not prove that the intended view rendered successfully. An error boundary or server error can appear at the correct path. A unique heading or control confirms destination readiness and makes the journey assertion stronger.

Q: How do you assert query parameters without depending on order?

I pass a predicate to toHaveURL, then read expected values through url.searchParams. For repeated keys, I use getAll and explicitly decide whether value order matters. I also constrain the pathname and any required origin.

Q: How do you test a route with a generated ID?

I use an anchored regex or predicate that validates the complete pathname and the ID format. After the web-first assertion passes, I can safely parse page.url() if a later step needs the identifier. I avoid loose substring matching.

Q: What changes when the link opens a new tab?

I start waiting for the popup before clicking, await the new Page, and run toHaveURL against that page. The opener's address normally remains unchanged. I limit external content assertions to systems within the test scope.

Q: Why is a positive access-denied route better than not.toHaveURL(/admin/)?

The negative assertion can pass on many unintended failures. A positive safe destination plus an access-denied heading proves the authorization behavior users should receive. It also produces clearer failure diagnostics.

Q: When would you choose URLPattern over a predicate?

I choose URLPattern when named route segments and structured URL components make the contract concise and the runtime supports it. I choose a predicate for compound logic, decoded query parameters, or team readability. Neither should be used to permit unexplained variation.

Common Mistakes

  • Reading page.url() immediately after an action and using a non-retrying generic assertion.
  • Matching only a route keyword without anchors, origin rules, or path boundaries.
  • Asserting raw query serialization when decoded parameter meaning is the requirement.
  • Forgetting to verify destination content after the URL matches.
  • Asserting the opener instead of a captured popup page.
  • Using ignoreCase in a canonicalization test where casing is the behavior.
  • Testing only a negative destination instead of the required safe route.
  • Logging authentication callback URLs with sensitive parameter values.
  • Reusing stored state that bypasses the route guard under test.
  • Hiding URL rules inside a generic substring helper.

Conclusion

The strongest playwright expect toHaveURL examples select a matching form that mirrors the navigation risk. Use strings for fixed routes, anchored patterns for constrained IDs, predicates for meaningful URL components, and URLPattern for clear route families. Capture the correct page and verify destination content.

Pick one fragile navigation test and replace its substring or immediate snapshot with a structured, retrying assertion. Document every allowed variation in code, then add the heading, panel, or guard message that proves the user reached the right state.

Interview Questions and Answers

Design a test for an anonymous user opening a protected report.

I start with clean unauthenticated storage, navigate to the report, and use a predicate to assert the login path plus the decoded return destination. I verify the sign-in view and then cover successful return navigation in a separate authenticated flow. This keeps guard and login behavior diagnosable.

How would you test search parameters whose order can vary?

I use a `toHaveURL` predicate and compare decoded values through `searchParams`. For repeated keys, I use `getAll` and state whether order matters. I also constrain the pathname and verify active filters or results on the page.

How do you validate a generated resource URL?

I match the complete pathname with an anchored format for the generated ID. I pair it with a destination heading or resource identity. If later steps need the ID, I parse it only after the retrying URL assertion has established the settled route.

What should a canonical redirect test verify?

It should verify the final canonical origin and path, required retained parameters, removed obsolete parameters, and destination content. If HTTP redirect status is part of the contract, I add a focused API-level check. I avoid `ignoreCase` when lowercase normalization is the behavior.

How would you test SPA back and forward behavior?

I establish a known route sequence, call `goBack` or `goForward`, and assert both the URL and restored view. I include filters or fragments when they are history state. I do not use network-idle as a substitute for the observable route and content.

How do you make popup URL tests race-safe?

I start `waitForEvent('popup')` before the action that opens the tab, then await the popup and assert that Page. This prevents missing a fast event. I keep third-party content outside scope unless the integration contract requires it.

What makes a URL assertion security-sensitive?

Origins, tenant boundaries, callback paths, and redirect parameters can determine where credentials or data are sent. I use explicit allowlist comparisons instead of hostname substrings and avoid logging secret parameter values. A positive safe destination is stronger than a broad negative check.

Frequently Asked Questions

What is a basic Playwright toHaveURL example?

Click the navigation control, then write `await expect(page).toHaveURL('/team')` with a configured `baseURL`. Add a unique heading assertion to prove that the destination view rendered.

How do I assert a login returnTo parameter?

Use a predicate that checks the login pathname and reads `url.searchParams.get('returnTo')`. This verifies the decoded destination without depending on raw query serialization.

How do I test a URL with a generated order ID?

Use an anchored regular expression or pathname predicate that constrains the full ID format. After the assertion passes, parse the settled `page.url()` only if later steps need the ID.

How do I test browser back navigation in Playwright?

Navigate through known views, call `page.goBack()`, and assert both the restored URL and destination content. For SPAs, do not rely on a response object because same-document history transitions can return `null`.

How do I test a hash-based tab with toHaveURL?

Use a predicate that checks both `url.pathname` and `url.hash`, then assert the tab panel and selected accessibility state. Also test a direct deep link if users can load the fragment in a fresh session.

How do I assert the URL of a newly opened tab?

Start a popup event promise before clicking, await the returned Page, and call `toHaveURL` on that popup. The original page is the wrong assertion target because its address usually does not change.

Why is not.toHaveURL often a weak authorization check?

It can pass on a blank page, unrelated error, or any unexpected route. Assert the required access-denied or login destination and its explanatory content to prove the safe behavior.

Related Guides