How to fix Playwright tests that pass despite wrong page due to weak URL checks?

Playwright

Weak URL assertions allow tests to pass on the wrong page, producing false positives that hide navigation bugs. expect(page.url()).toContain('/settings') will pass on /account/settings, /billing/settings, /admin/settings, and any other route that contains the substring — including paths you never intended. Tests that only loosely verify the current URL provide false confidence about navigation behavior.

Common mistake

test('navigates to security settings', async ({ page }) => {
  await page.goto('https://app.example.com');

  await page.getByRole('link', { name: 'Security' }).click();

  // Too loose — passes on /settings/password, /settings/security, /settings/security?tab=2fa
  expect(page.url()).toContain('/settings');
});

Also weak: using .toContain() for URLs where query parameters or hash fragments are significant:

// Passes even if tab=general instead of tab=security
expect(page.url()).toContain('/account/settings');

The fix

Use expect(page).toHaveURL() with a regex that anchors the meaningful parts of the URL:

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

test('navigates to security settings', async ({ page }) => {
  await page.goto('https://app.example.com/account');

  await page.getByRole('link', { name: 'Security' }).click();

  // Anchored regex — requires exact path segment
  await expect(page).toHaveURL(/\/account\/settings\/security$/);
});

test('opens billing with correct tab', async ({ page }) => {
  await page.goto('https://app.example.com/settings');
  await page.getByRole('tab', { name: 'Billing' }).click();

  // Assert both path and query parameter
  await expect(page).toHaveURL(/\/settings\?tab=billing/);
});

test('redirects to dashboard after login', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Exact path match — no substring ambiguity
  await expect(page).toHaveURL('https://app.example.com/dashboard');
});

For cases where the path is known but the origin varies by environment:

// Use waitForURL with a relative pattern when baseURL is configured
await expect(page).toHaveURL(/\/dashboard$/);

Why it works

expect(page).toHaveURL() retries with Playwright's assertion loop — it waits for the URL to match the pattern rather than checking the current URL snapshot at call time. This handles asynchronous redirects gracefully. Using a regex with start (^) and end ($) anchors, or embedding the exact expected path segment, prevents partial substring matches from hiding navigation to the wrong route. The auto-retry behavior is also useful after click actions that trigger a navigation: the assertion waits for the navigation to complete rather than failing on the URL before the redirect fires.

Tips

  • expect(page).toHaveURL() accepts a string for exact match, a regex for pattern matching, and a URL object for structured URL matching — use the form that matches the precision the test needs.
  • For navigation tests, assert both the URL and a key heading or element that confirms the correct page rendered — URL alone can pass if the router lands on a route but the page component crashes.
  • Use page.waitForURL('**/expected-path') when you need to wait for navigation without making an assertion — useful in setup code before the actual test assertions begin.
  • Loose URL checks in smoke tests are particularly dangerous because they're intended to catch regressions, but they'll pass through broken routing silently.