Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Load the Angular entry document once with page.goto(), then exercise client-side routes through a visible RouterLink or application control. Start page.waitForNavigation() before the click, and verify both the resulting URL and a route-specific DOM marker. Because Angular normally uses the History API, the wait can resolve with null; that is expected and is not a failed navigation.

The reliable pattern

A single-page Angular application does not request a new HTML document for every route. After the shell has loaded, Angular Router changes the URL and replaces the routed view in place. Puppeteer should therefore test the same user action that a visitor uses rather than calling page.goto() for every route.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();

try {
  await page.goto('https://example.test/', {
    waitUntil: 'domcontentloaded'
  });

  await Promise.all([
    page.waitForNavigation({waitUntil: 'domcontentloaded'}),
    page.locator('a[routerLink="/orders"]').click()
  ]);

  await page.waitForSelector('[data-testid="orders-page"]', {
    visible: true
  });

  if (!page.url().endsWith('/orders')) {
    throw new Error(`Unexpected route: ${page.url()}`);
  }
} finally {
  await browser.close();
}

The important details are the order of operations and the final assertion. Promise.all() arms the navigation listener before the click, preventing a race in which the route changes before Puppeteer starts waiting. The route-specific marker proves that Angular rendered the intended component; a URL check alone can pass while the view is still loading or incorrect.

Why waitForNavigation() can return null

Puppeteer treats a History API URL change as navigation, but a client-side route transition does not necessarily create a new main-resource response. Consequently, page.waitForNavigation() may resolve to null after an Angular pushState transition. Do not write a test that requires a non-null response for every route.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const [response] = await Promise.all([
  page.waitForNavigation({waitUntil: 'domcontentloaded'}),
  page.locator('a[routerLink="/orders"]').click()
]);

// response may be null for an Angular History API transition.
await page.waitForSelector('[data-testid="orders-page"]', {visible: true});
await page.waitForFunction(
  () => document.body.dataset.route === 'orders'
);

Use the response object only when you specifically need to inspect a document request. For ordinary Angular routing, the useful evidence is the new URL plus a stable element, heading, landmark, or test ID belonging to the destination view.

Choose the correct Angular navigation mechanism

RouterLink in a template

A declarative link is the closest equivalent to a real user action:

<a routerLink="/orders">Orders</a>

In Puppeteer, prefer a selector that is stable and visible. A test ID can be less fragile than text that changes with localization:

await page.locator('[data-testid="orders-link"]').click();

router.navigate() and navigateByUrl()

Application code can navigate programmatically:

await router.navigate(['/orders']);
await router.navigateByUrl('/orders');

navigate() builds a route from command segments, while navigateByUrl() accepts an absolute route path. Both return a promise. A resolved false means navigation did not succeed; a rejected promise indicates an error. In an end-to-end test, trigger these methods through the UI when possible so guards, event handlers, and accessibility behavior are exercised as they are for a user.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Synchronize with rendered state, not a fixed delay

waitForSelector() waits for an element to be added to the DOM and has a 30-second default timeout. It continues to work across navigations. Use a marker that is unique to the destination:

await page.waitForSelector('main h1', {visible: true});
await page.waitForSelector('[data-route="orders"]', {visible: true});

For a semantic state exposed by the application, use a predicate:

await page.waitForFunction(
  () => document.body.dataset.route === 'orders',
  {timeout: 30000}
);

A fixed waitForTimeout() can be too short on a busy runner and unnecessarily slow on a fast one. If the destination waits for an API response, combine the navigation signal with the rendered marker. If the URL never changes, omit waitForNavigation() and wait directly for the state transition:

await page.locator('[data-testid="refresh-orders"]').click();
await page.waitForSelector('[data-testid="orders-loaded"]', {visible: true});

Path and hash routing

Angular supports PathLocationStrategy and HashLocationStrategy. Path strategy uses pushState-style URLs such as /orders; hash strategy uses URLs such as /#/orders. Path strategy is the usual default, but tests must match the strategy actually deployed by your application.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

PathLocationStrategy assertions

if (!new URL(page.url()).pathname.endsWith('/orders')) {
  throw new Error('Expected the /orders path');
}

A direct request to https://example.test/orders is a real document request. Your web server must return the Angular entry document for that deep link. Without an SPA fallback, the server can return a 404 before Angular starts, even though in-app navigation from the home route works.

HashLocationStrategy assertions

if (!page.url().endsWith('/#/orders')) {
  throw new Error('Expected the hash route');
}

For a hash route, the server receives the base document URL and Angular reads the route after the #. A test that looks only for /orders in the pathname will miss the actual route.

Do not switch strategies casually. Existing links, bookmarks, server rewrites, analytics, and production visitors depend on the URL shape. Decide the strategy early and keep assertions and deployment configuration consistent with it.

RouterLink clicks versus page.goto()

Decision RouterLink or control click Direct page.goto()
What it tests Real in-app navigation, guards, and routed rendering Initial load or deep-link server handling
Document reload Normally none for an SPA route change Loads a document for the target URL
Synchronization waitForNavigation() started before the click, then a routed DOM assertion goto() completion, then a DOM assertion
Server requirement The SPA entry document is already loaded The server must serve the entry document for the deep link
URL shape Path or hash, according to Angular’s strategy The same deployed URL strategy

Use page.goto() for the first document, for a deliberate full navigation, or for a test specifically checking server-side deep-link handling. It is not a substitute for exercising Angular Router.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Guards, redirects, and asynchronous route data

A guard can redirect /orders to /login, and a resolver or component can render only after data arrives. Assert the outcome your test actually requires:

await Promise.all([
  page.waitForNavigation(),
  page.locator('a[routerLink="/orders"]').click()
]);

await page.waitForSelector('[data-testid="login-page"]', {visible: true});
if (!page.url().endsWith('/login')) {
  throw new Error('Unauthenticated users should be redirected to login');
}

For an authenticated case, establish the session before clicking and wait for the destination’s loaded marker rather than assuming that the route event means the data view is ready. If a click opens a same-document route and starts an independent API request, wait for both the route marker and the application-specific loaded state.

Common failures and precise fixes

The wait times out

  • Cause: The selector does not match the rendered link, the link is hidden, or a guard sends the user elsewhere.
  • Fix: Confirm the selector with page.locator(...).count(), make the control visible, and log page.url() after the click. Assert the redirect route when a guard is expected.

The test misses the navigation

  • Cause: The click was awaited before waitForNavigation() was registered.
  • Fix: Put both promises in Promise.all(), with waitForNavigation() listed first.

The response is null

  • Cause: Angular changed the URL through the History API without a new main-resource response.
  • Fix: Accept the null response and assert the URL plus a destination DOM marker.

The URL changes but the old view remains

  • Cause: The test stops at the navigation event while Angular is still rendering or loading route data.
  • Fix: Wait for a unique heading, landmark, test ID, or application state predicate from the new component.

A direct deep link returns 404

  • Cause: The server is not configured to fall back to the SPA entry document for path routes.
  • Fix: Test in-app navigation separately and configure the production web server’s history fallback before testing direct page.goto() requests.

The assertion never matches a hash route

  • Cause: The test expects /orders in the pathname while the application uses #/orders.
  • Fix: Assert the hash form and inspect the complete URL with page.url().

The click does not change the URL

  • Cause: The control updates view state without routing, or the click handler is blocked by validation or another overlay.
  • Fix: Skip waitForNavigation() for same-route state changes and wait for the relevant element or predicate. If routing is intended, remove the overlay and verify the control’s event path.

Reliability and performance practices

  • Launch one browser and reuse it for related tests; create isolated pages or contexts for separate sessions.
  • Use domcontentloaded for the initial shell when the test’s real readiness condition is a routed marker. Waiting for every image or third-party request can add unnecessary variance.
  • Keep route markers stable with data-testid or data-route attributes rather than styling classes.
  • Set an explicit timeout appropriate to your CI environment, but do not hide slow application behavior with an arbitrarily long delay.
  • Capture the URL, console errors, and a screenshot when a route assertion fails. These artifacts distinguish a selector problem from a guard, server, or runtime failure.
  • Run a separate deep-link test for each important path. In-app navigation can pass while a fresh request still fails at the web-server layer.

A complete test with both path and rendered-state checks

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
page.setDefaultTimeout(30000);

page.on('console', message => {
  console.log(`[browser:${message.type()}] ${message.text()}`);
});

try {
  await page.goto('https://example.test/', {
    waitUntil: 'domcontentloaded'
  });

  await page.waitForSelector('[data-testid="orders-link"]', {
    visible: true
  });

  await Promise.all([
    page.waitForNavigation({waitUntil: 'domcontentloaded'}),
    page.locator('[data-testid="orders-link"]').click()
  ]);

  await page.waitForSelector('[data-testid="orders-page"]', {
    visible: true
  });
  await page.waitForFunction(
    () => document.body.dataset.route === 'orders'
  );

  const current = new URL(page.url());
  if (current.pathname !== '/orders') {
    throw new Error(`Expected /orders, got ${current.pathname}`);
  }
} catch (error) {
  console.error('Angular route test failed at', page.url(), error);
  throw error;
} finally {
  await browser.close();
}

This test deliberately does not reject a null navigation response. Its success criteria are the route URL, the destination element, and the application’s explicit route state.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your goal is a clean image or PDF of a routed page rather than an end-to-end test of Angular behavior, ScreenshotNeo provides a single HTTP request. It accepts the page URL and returns a PNG, JPEG, WebP, or PDF; its cleanup options can accept the cookie banner and remove more than 60 known consent platforms, newsletter popups, and chat widgets before capture.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For an Angular route, pass the deployed path or hash URL directly. This is a capture service, not a replacement for testing guards and router events:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.test/orders -o orders.webp

See the ScreenshotNeo API documentation for parameters. Only clean shots are billed; bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and each response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server supplies take_screenshot, get_page_info, and capture_pdf tools to Claude, Cursor, and other MCP clients.

The free plan includes 1,000 screenshots per month without a card. Paid plans start at $5 for 3,000 shots, and every feature is included on every plan. Create a free ScreenshotNeo account to try the capture request.

FAQ

Should I wait for networkidle on every Angular route?

No. Analytics, websockets, and other long-lived requests can prevent network idle from representing visual readiness. Prefer a route-specific marker and wait for the particular data state your component exposes.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can I test a route without clicking a link?

Yes. Use page.goto() when you are intentionally testing a fresh deep link and its server fallback. For a router-only test, trigger the application control or call the router from application code, then assert the routed view.

Best Value
The SQL Programming Language: .
  • Used Book in Good Condition

What should a route assertion contain?

Use the URL shape required by your location strategy and one stable marker owned by the destination component. Together they verify both navigation state and rendered content.

Frequently Asked Questions

Does a null value from waitForNavigation mean Angular failed?

No. History API routing can complete without a new document response, so Puppeteer may return null. Verify the final URL and a destination-specific DOM marker instead.

Why does an Angular deep link work after a click but fail with page.goto()?

A click starts with the already loaded SPA shell. page.goto() asks the web server for the deep URL, which requires an SPA entry-document fallback for path-based routing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When should I omit waitForNavigation entirely?

Omit it when a control changes view state without changing the URL. Wait for the resulting locator, selector, or function predicate instead.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.