Resource library

QA How-To

How to Use Playwright expect toHaveURL (2026)

Learn playwright expect toHaveURL with strings, regex, predicates, URLPattern, baseURL, redirects, SPA routes, query checks, timeouts, and debugging tips.

24 min read | 2,665 words

TL;DR

Use `await expect(page).toHaveURL(expected)` after the action that changes navigation. Strings suit fixed routes, regexes suit constrained dynamic segments, predicates suit parsed query and compound rules, and `URLPattern` suits structured route families. Add a destination-content assertion.

Key Takeaways

  • Pass the Page to `await expect(page).toHaveURL(expected)` so navigation checks can retry.
  • Use relative strings with `baseURL` when the application route is stable across environments.
  • Choose predicates for decoded query parameters and compound origin, path, or hash rules.
  • Use anchored regular expressions for constrained dynamic segments and `URLPattern` for readable route families.
  • Pair every important URL assertion with destination-specific content or state evidence.
  • Capture popups and assert the new Page rather than waiting on the opener's URL.
  • Redact sensitive callback data and treat origin allowlists as part of security-sensitive navigation tests.

Use playwright expect toHaveURL by passing the Page to expect, awaiting the assertion, and matching the final address with a string, regular expression, URLPattern, or predicate. The assertion retries while navigation, redirects, or client-side routing complete, which makes it safer than comparing a one-time page.url() value.

URL checks are most valuable when navigation itself is part of the user contract. A strong test verifies the destination without coupling to volatile hosts, query ordering, tracking parameters, or generated identifiers. This 2026 guide covers every supported matching style, baseURL, redirects, single-page applications, query and hash checks, timeouts, security-sensitive transitions, and practical debugging.

TL;DR

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

test('opens the account page', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Account' }).click();
  await expect(page).toHaveURL('/account');
});
Expected form Best for
Exact or relative string Stable complete destination
Regular expression One constrained dynamic segment
URLPattern Structured host, path, or route patterns
Predicate receiving URL Query parameters and compound URL rules

Configure baseURL when using relative strings. Prefer semantic query checks in a predicate when parameter order is not a requirement. Use page.waitForURL for synchronization needed before subsequent operations, and expect(page).toHaveURL when the destination is a test assertion.

1. What playwright expect toHaveURL asserts

toHaveURL belongs to Playwright's page assertions. Its received value is a Page, not a locator or plain string. Playwright checks the page's current main-frame URL repeatedly until it matches the expectation or reaches the assertion timeout.

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

test('navigates to documentation', async ({ page }) => {
  await page.route('https://app.test/**', async route => {
    const path = new URL(route.request().url()).pathname;
    const body = path === '/'
      ? '<a href="/docs/getting-started">Read the guide</a>'
      : '<h1>Getting started</h1>';
    await route.fulfill({ status: 200, contentType: 'text/html', body });
  });

  await page.goto('https://app.test/');
  await page.getByRole('link', { name: 'Read the guide' }).click();

  await expect(page).toHaveURL('https://app.test/docs/getting-started');
});

This spec is self-contained because routing fulfills both documents in memory. It demonstrates the important sequence: a user action initiates navigation, then the assertion verifies the destination.

The assertion does not prove that the destination page is usable. A server can return an error document at the expected path, or a single-page application can update the URL before rendering content. Add a destination-specific assertion such as an accessible heading, unique form, or loaded account name. URL and page content are complementary signals.

It also does not validate every request made during navigation. Subresources, API calls, and iframe locations can fail while the main page URL looks correct. Assert those layers separately when they are part of the risk. For an embedded payment frame, a main-page toHaveURL('/checkout') says nothing about the frame's provider state.

Treat the page object passed to expect as part of the contract. In a test with an opener, popup, and multiple tabs, each Page has an independent current URL. Naming page variables by role, such as adminPage or invoicePage, reduces the chance that an otherwise correct assertion watches the wrong browser surface.

2. Exact strings and relative URLs with baseURL

Use an exact string when the complete destination is stable and important:

await expect(page).toHaveURL('https://app.example.com/settings/profile');

Exact matching includes the URL's query string and fragment. If the actual address gains ?source=nav, a string without it should fail. That strictness is valuable when every component belongs to the contract, but fragile when analytics parameters are irrelevant.

Relative string expectations become especially useful with baseURL:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'http://127.0.0.1:3000',
  },
});
test('opens profile settings', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Profile settings' }).click();
  await expect(page).toHaveURL('/settings/profile');
});

When context options provide baseURL and the expectation is a string, Playwright merges them with the URL constructor. This keeps the test portable across local, staging, and preview hosts. Review base paths carefully. With URL resolution, a leading slash replaces the base path, while a path without a leading slash resolves relative to the base URL's directory semantics.

Do not hardcode a production hostname simply to make an assertion look complete. The host should be exact only when cross-origin navigation or tenant routing is itself under test.

3. Regular expressions for dynamic paths

A regular expression is appropriate when part of the URL varies but follows a strict format.

await expect(page).toHaveURL(
  /^https:\/\/app\.example\.com\/orders\/ORD-\d{6}$/,
);

Escape dots in hostnames because an unescaped . matches any character. Anchor the pattern when the whole address matters. A loose /orders/ expression could pass /orders-error, /admin/orders, or a query parameter containing the word.

If only the pathname is relevant and the host changes by environment, a full URL regex can become noisy. A predicate provides structured access without embedding all URL syntax in one expression:

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

Regular expressions remain ideal for a single dynamic segment or a small family of routes. They are less readable for multiple query rules, optional ports, or several independent conditions. Avoid patterns filled with .* that merely search for a keyword. Such checks often pass unexpected routes and make security-sensitive redirect tests especially weak.

The expected RegExp is evaluated against the current URL string during retries. Use the ignoreCase option only if path or host casing is intentionally irrelevant to the product contract.

4. Predicates for query parameters and compound rules

A predicate receives a standard URL object and returns a boolean. This is the clearest choice when query parameter order should not matter.

await expect(page).toHaveURL(url => {
  return url.pathname === '/search'
    && url.searchParams.get('q') === 'playwright assertions'
    && url.searchParams.get('sort') === 'recent';
});

The predicate can use pathname, hostname, protocol, hash, and searchParams. Keep it pure and fast because Playwright may call it repeatedly. It should not perform network requests, mutate shared state, or contain assertions of its own.

For repeated query keys, inspect all values:

await expect(page).toHaveURL(url => {
  const tags = url.searchParams.getAll('tag');
  return url.pathname === '/issues'
    && tags.length === 2
    && tags.includes('api')
    && tags.includes('critical');
});

This permits either parameter order while still requiring exactly the expected tags. If order affects the application, compare the array directly instead.

Predicates can accidentally become too permissive. Returning only url.searchParams.has('token') may pass on the wrong path or host. State every security or routing boundary relevant to the scenario. A strong OAuth callback assertion might require the approved origin, callback pathname, state parameter, and absence of an error parameter without logging any secret token.

5. URLPattern for structured route matching

toHaveURL supports URLPattern, which expresses structured URL components without one large regular expression.

await expect(page).toHaveURL(new URLPattern({
  protocol: 'https',
  hostname: 'app.example.com',
  pathname: '/projects/:projectId/settings',
}));

This pattern communicates which components matter and names the dynamic path segment. Another useful form constrains a family of documentation routes:

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

Use URLPattern when your supported JavaScript runtime provides it and the structured notation is clearer for the team. If the project's runtime needs a polyfill, manage that explicitly rather than assuming every Node environment exposes the global. A predicate remains universally understandable and gives direct URL access for detailed query logic.

Do not select URLPattern merely because it is newer. Exact strings communicate fixed destinations better, and predicates communicate compound business rules better. The best matcher form is the one reviewers can verify against the navigation requirement with the fewest hidden allowances.

6. Redirect chains and authentication navigation

Authentication flows often pass through several URLs before reaching a stable destination. Assert the state the scenario owns.

test('redirects an anonymous user to sign in', async ({ page }) => {
  await page.goto('/billing');

  await expect(page).toHaveURL(url =>
    url.pathname === '/login'
    && url.searchParams.get('returnTo') === '/billing',
  );
  await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});

This verifies both protection and preservation of the intended destination. Encoding may appear different in a raw string, so searchParams.get is safer than asserting the full query text when query ordering and encoding are not requirements.

For a completed login, assert the final application route rather than every identity-provider hop. Third-party hosts and parameters change independently and can contain secrets. If your organization owns an intermediate callback route, test its required state with controlled, sanitized fixtures.

Never print an entire callback URL if it contains authorization codes, tokens, or personal data. Prefer assertions on presence, allowed origin, exact state fixture, and final redirection. Redact sensitive values in custom diagnostics and traces when necessary.

The Playwright authentication best practices guide covers storage state and test isolation that complement these redirect checks.

7. Single-page applications and History API changes

Single-page applications often update the address through pushState or a router without a new document request. toHaveURL observes the page URL and can wait for those transitions.

test('opens a client-side settings route', async ({ page }) => {
  await page.setContent(`
    <a href="/settings" id="settings">Settings</a>
    <main><h1>Home</h1></main>
    <script>
      document.querySelector('#settings').addEventListener('click', event => {
        event.preventDefault();
        history.pushState({}, '', '/settings');
        document.querySelector('h1').textContent = 'Settings';
      });
    </script>
  `, { waitUntil: 'domcontentloaded' });

  await page.getByRole('link', { name: 'Settings' }).click();
  await expect(page).toHaveURL(/\/settings$/);
  await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});

The example uses a regex because setContent begins from the current page context and the host is not the behavior under test. In a normal application with baseURL, a relative string is clearer.

URL change and view readiness can happen at different moments. A router may update history before fetching route data. The URL assertion proves navigation state, while the heading or loaded record proves render readiness. Do not assume one replaces the other.

If a click can trigger either same-document routing or full navigation depending on feature flags, assert the user-visible destination contract and avoid implementation-specific waits such as waitForLoadState('networkidle') as the primary signal.

8. Query strings, fragments, and encoding

Exact strings are sensitive to parameter order and encoding. Browsers may serialize spaces, Unicode, and repeated parameters in ways that differ from hand-built expectations. Use URL semantics when serialization is not itself the behavior.

await expect(page).toHaveURL(url => {
  return url.pathname === '/catalog'
    && url.searchParams.get('q') === 'test automation'
    && url.searchParams.get('page') === '2'
    && url.hash === '#results';
});

This checks decoded query values and the fragment explicitly. URLSearchParams.get returns a string or null, so compare with the expected string representation. For flags such as ?preview, use has('preview') if presence is the contract, and combine it with the required path and host.

Fragments do not cause a server request, but they can control tabs, anchors, or client-side state. Assert the hash only when users depend on deep linking or back-button behavior. A decorative fragment introduced by an internal widget should not be included in an exact URL expectation.

Avoid manually decoding the whole URL string. The URL object already provides parsed components. Manual string splitting is error-prone for encoded delimiters and repeated keys.

9. toHaveURL versus waitForURL and page.url

These APIs have distinct roles.

API Retries or waits Primary purpose
expect(page).toHaveURL(expected) Retries assertion Verify destination as test evidence
page.waitForURL(expected) Waits for main frame navigation Synchronize control flow
page.url() Immediate snapshot Read or log the current address

Use an assertion when a wrong destination should be reported as the failed requirement:

await page.getByRole('link', { name: 'Invoices' }).click();
await expect(page).toHaveURL('/invoices');

Use waitForURL when later code needs navigation to complete but the URL itself is not the behavior being asserted, such as coordinating a popup workflow. Use page.url() when code must parse a settled address or attach a diagnostic. A generic expect(page.url()).toBe(...) compares one snapshot and does not gain page-assertion retries.

Do not call both waitForURL('/invoices') and toHaveURL('/invoices') routinely for the same event. That duplicates the wait. If the destination is an assertion, toHaveURL normally provides the needed waiting and evidence. If a navigation promise must be started before a click to avoid a race in a particular workflow, use the appropriate wait pattern and assert the destination content afterward.

The same separation applies to page.waitForResponse. A response wait can synchronize with a request needed for later inspection, but it does not prove navigation. Conversely, a URL assertion does not prove that a specific API returned correct data. Combine signals only when the scenario genuinely covers both contracts, and give each failure a focused expectation.

Avoid using load-state waits as a substitute for a route assertion. domcontentloaded and load describe document lifecycle events, while networkidle describes a period of network quiet. None of them says the user reached the intended path, and client-side routes may change without a new document lifecycle.

10. Configuring playwright expect toHaveURL timeouts and ignoreCase

Set an expect timeout centrally for ordinary assertions:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://127.0.0.1:3000' },
  expect: { timeout: 7_000 },
});

Override it for an explicitly slower navigation:

await expect(page).toHaveURL('/reports/complete', {
  timeout: 20_000,
});

The ignoreCase option performs case-insensitive matching for supported expected forms and takes precedence over a regular expression's case flag. A predicate ignores that option because the predicate owns its comparison logic.

await expect(page).toHaveURL(/\/account\/overview$/, {
  ignoreCase: true,
});

Use case-insensitive matching only when the route contract permits it. URL paths can be case-sensitive depending on the server. Hiding /Account versus /account differences may allow a link that fails in production on one environment.

Increasing the timeout is not a fix for a link that opens a new tab, a route guard that blocks navigation, or an incorrect base path. Inspect the action and the actual page first. The assertion waits on the Page object you pass, so it cannot observe a popup page unless you capture and assert that popup.

11. Page objects and reusable route contracts

Keep route knowledge small and explicit. A page object can expose navigation methods, while the scenario asserts its expected destination.

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

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

  async openBilling(): Promise<void> {
    await this.page.getByRole('link', { name: 'Billing' }).click();
  }
}
const dashboard = new DashboardPage(page);
await dashboard.openBilling();
await expect(page).toHaveURL('/settings/billing');
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();

A centralized route map can help when paths are stable domain constants:

export const routes = {
  billing: '/settings/billing',
  profile: '/settings/profile',
} as const;

Avoid a generic assertCurrentUrl(fragment) helper that always builds a loose regex. It hides whether the scenario expects a full destination, pathname, query rule, or origin boundary. A small predicate factory may be useful for repeated tenant routes, but its name and types should expose all allowed variation.

The related Playwright page object model guide explains how to keep selectors and business expectations at sensible layers.

12. Debugging failed URL assertions

Read the received URL and call log first. Then classify the failure: no navigation occurred, the wrong page object was asserted, an intermediate redirect persisted, the expected serialization is too strict, or the application reached a fallback route.

Capture structured diagnostics without leaking secrets:

const current = new URL(page.url());
console.log({
  origin: current.origin,
  pathname: current.pathname,
  queryKeys: [...current.searchParams.keys()],
  hash: current.hash,
});

Do not log query values when they can include tokens or personal information. The pathname and key names are often enough to identify the mismatch.

Compare a failing run with the route produced by the same action in a fresh context. Persistent cookies, service workers, feature flags, and saved locale can change redirect behavior. If the isolated run passes, inspect setup and storage state before labeling the failure flaky. Route expectations often expose leaked authentication state that content assertions miss.

If the link opens a new page, capture it and assert that object:

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

await expect(popup).toHaveURL(/\/invoices\/\d+$/);

Use Trace Viewer to inspect the click target, navigation events, redirects, and DOM snapshots. The Playwright Trace Viewer debugging guide provides a repeatable triage workflow. Fix the route contract or app behavior before loosening the expectation.

Interview Questions and Answers

Q: Why use toHaveURL instead of expect(page.url()).toBe()?

toHaveURL is a page assertion that retries while navigation or client-side routing completes. page.url() returns one immediate string snapshot, and a generic matcher compares it once. The page assertion also produces navigation-focused diagnostics.

Q: How does baseURL affect a string expectation?

When the browser context has baseURL and the expected URL is a string, Playwright resolves them using the URL constructor. This lets tests assert relative application routes across environments. I still review leading-slash behavior and any base path carefully.

Q: When is a predicate better than a regular expression?

A predicate is better for multiple structured rules, especially query parameters whose order should not matter. It receives a URL object, so I can compare pathname, origin, hash, and decoded parameters directly. I keep the predicate pure because it may run repeatedly.

Q: Does toHaveURL prove the destination page loaded correctly?

No. It proves the page reached a matching address. I pair it with a destination-specific assertion such as a heading, account identifier, or functional control to prove the expected view is ready.

Q: How do you assert a URL opened in a new tab?

I start page.waitForEvent('popup') before the click, await the captured popup, and pass that popup Page to expect. Asserting the original page would time out because its URL did not change.

Q: What is the difference between waitForURL and toHaveURL?

waitForURL is primarily synchronization for main-frame navigation, while toHaveURL records a test expectation. I avoid duplicating both for the same destination unless the workflow has a separate control-flow need and a distinct assertion.

Common Mistakes

  • Comparing page.url() once immediately after a click that starts asynchronous navigation.
  • Using a loose regex such as /dashboard/ that also matches unintended routes or query values.
  • Hardcoding an environment hostname when only the application path matters.
  • Asserting an exact raw query string when parameter order and encoding are irrelevant.
  • Checking only a token parameter without constraining the callback path and origin.
  • Assuming the URL proves that destination content loaded successfully.
  • Passing the original page to the assertion when a link opened a popup.
  • Increasing timeouts without checking route guards, click behavior, and redirects.
  • Logging complete callback URLs that contain codes, tokens, or personal data.

Conclusion

The dependable playwright expect toHaveURL pattern is await expect(page).toHaveURL(expected). Use relative strings for stable app routes, constrained regexes for one dynamic segment, URLPattern for readable structured patterns, and predicates for compound or query-aware rules. Pair the address with a destination-content assertion.

Refactor one navigation test that reads page.url() too early. Choose the smallest URL components that represent the user contract, preserve required origin boundaries, and let the page assertion retry while the application completes its route transition.

Interview Questions and Answers

Why is toHaveURL preferable to a generic page.url assertion after a click?

`toHaveURL` retries while navigation, redirects, or client-side routing complete. `page.url()` returns a single string snapshot, and a generic matcher checks it once. The page assertion also produces URL-focused call logs.

How do you select between a string, regex, URLPattern, and predicate?

I use a string for a fixed destination, a constrained regex for limited dynamic syntax, and `URLPattern` for a readable structured route family. I use a predicate for decoded query parameters or several component rules. The chosen form should expose every allowed variation to reviewers.

How does baseURL influence toHaveURL?

A string expectation is resolved against the context's `baseURL` through standard URL construction. This allows portable relative route assertions. I verify leading slash and base-path semantics so the resolved destination matches the application deployment.

How do you test an OAuth callback URL safely?

I constrain the approved origin and callback path, verify controlled state and required parameter presence, and assert that no error parameter exists. I do not log authorization codes, tokens, or full sensitive URLs. I then assert the final application destination separately.

Does a matching URL prove successful navigation?

It proves the page address matches, not that the correct view loaded. An error document or SPA error boundary can exist at the expected path. I pair the URL with unique destination content or functional state.

How do you assert navigation in a new tab?

I create the popup event promise before clicking, await the new `Page`, and pass that page to `toHaveURL`. I then assert destination content if the external system is within scope. The opener and popup have independent URLs.

How would you debug a toHaveURL timeout?

I inspect whether the action fired, which Page was asserted, and the redirect timeline in the trace. I parse the current origin, path, query keys, and hash without exposing secrets. I also check storage state, route guards, feature flags, and service workers before changing the matcher.

Frequently Asked Questions

How do I use toHaveURL in Playwright?

Pass the page to `expect` and await `toHaveURL` with a string, regex, `URLPattern`, or predicate. The assertion retries while the main page URL changes.

Can Playwright toHaveURL use a relative URL?

Yes, when the browser context has `baseURL`. Playwright resolves a string expectation with the `URL` constructor, so verify leading slashes and base-path behavior in your configuration.

How do I check query parameters with toHaveURL?

Pass a predicate and inspect the received `URL` object's `searchParams`. This avoids brittle dependence on raw parameter order and encoding while allowing exact checks on required path and origin.

Does toHaveURL wait for navigation?

It retries the page URL until the expectation matches or the assertion timeout expires. It can observe document navigation and client-side route updates, but destination content still needs its own assertion.

What is the difference between waitForURL and toHaveURL?

`waitForURL` primarily synchronizes control flow with main-frame navigation. `toHaveURL` expresses a test expectation and reports a wrong destination as the failed requirement.

Can toHaveURL check a popup?

Yes. Capture the popup `Page` by starting `waitForEvent('popup')` before the click, then call `await expect(popup).toHaveURL(...)`. The opener's URL usually remains unchanged.

How do I change the toHaveURL timeout?

Pass `{ timeout: milliseconds }` as the second matcher argument or configure the project-wide expect timeout. Increase it only for a justified slow route, after ruling out blocked navigation, wrong pages, and incorrect route expectations.

Related Guides