Playwright tests pass on wrong page — weak URL checks
PlaywrightWeak 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.