How to Build a Reproducible Test Project for SPA Route Guards, 404 Handling, and Auth Redirect Loops
By Markus Gasser · August 26, 2026
Build a small SPA test project that catches broken route guards, client-side 404 handling, expired-session redirects, and auth redirect loops with stable browser automation assertions.
A single-page app can look healthy while its navigation is broken in ways users immediately feel: protected routes leak content, bad URLs render blank screens instead of a useful 404, and expired sessions bounce forever between login and app screens. The tests that catch these regressions are usually small. The hard part is making them reproducible, readable, and resistant to implementation churn.
This project walks through a minimal SPA with client-side routing and auth-aware redirects, then adds browser automation checks for the exact behaviors that matter. The goal is not to test every router detail. The goal is to test spa route guards and auth redirects with assertions that survive refactors.
What we are testing, and what we are not
Before writing code, separate three behaviors that are often mixed together:
- Route guard: logic that blocks a route unless a condition is true, usually authentication.
- 404 handling: what the app does when the URL does not match any client-side route.
- Redirect loop: repeated navigation between two or more routes, often caused by expired sessions or inconsistent auth state.
A good test does not prove router internals. It proves the user ends up on the correct page, with the correct URL, and no repeated navigation occurs.
For this project, the useful assertions are:
- The protected page is not visible when the session is missing.
- The user lands on
/login?returnTo=...or an equivalent login route when blocked. - A bad URL shows the app’s not-found state and the final URL remains stable.
- An expired session triggers one redirect to login, not a loop.
- A completed login sends the user back to the originally requested route.
Those assertions are enough to catch real regressions without binding the test to the router’s internal APIs.
Project shape
The smallest reproducible setup has three moving parts:
- a frontend app with a router,
- a predictable auth state stub,
- browser automation tests that run against a real browser.
You can use any SPA stack. The examples below assume React Router because its route guard and catch-all patterns are easy to show, but the testing shape applies to Vue Router, SvelteKit client navigation, or custom routing logic.
Suggested app routes
/public home/dashboardprotected route/loginlogin page*or catch-all route for 404 handling
Suggested auth model for the demo
Use a tiny auth object rather than a real identity provider. The tests should control state, not depend on a live backend. A simple in-memory session flag is enough for route-guard and redirect behavior.
Minimal app implementation
A route guard can be as simple as a wrapper that checks session state and redirects if needed.
import { Navigate, useLocation } from 'react-router-dom';
export function RequireAuth({ isAuthenticated, children, }: { isAuthenticated: boolean; children: React.ReactNode; }) { const location = useLocation();
if (!isAuthenticated) {
return (
<Navigate
to={/login?returnTo=${encodeURIComponent(location.pathname)}}
replace
/>
);
}
return <>{children}</>; }
A catch-all route for 404 handling should render a visible page, not a blank shell.
export function NotFound() {
return (
<main>
<h1>Page not found</h1>
<p>The URL does not match any route in this app.</p>
</main>
);
}
The login page should preserve the return path.
import { useSearchParams, useNavigate } from 'react-router-dom';
export function LoginPage({ signIn }: { signIn: () => Promise
return ( <button onClick={async () => { await signIn(); navigate(returnTo, { replace: true }); }} > Sign in </button> ); }
This is enough application code to support the tests.
Test strategy that stays stable
Use browser automation for end-to-end assertions, but keep the assertions at the page boundary:
- URL path and search params
- visible heading or role-based text
- absence of protected content
- redirect count or navigation stability when needed
Avoid asserting exact router implementation details, such as component names, internal state, or history stack shape. Those are fragile and usually not the user-facing contract.
Playwright is a good fit here because it gives you page-level URL checks, text assertions, and access to network or navigation events when you need them. The same project can be adapted to Cypress or Selenium, but the examples below use Playwright because the assertions are concise and explicit.
Build the tests in layers
1) Protected route sends unauthenticated users to login
This test verifies the guard and the return path.
import { test, expect } from '@playwright/test';
test('unauthenticated users are redirected from dashboard to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await expect(page.getByText('Dashboard')).toHaveCount(0);
});
Why this works:
- It checks the final URL, not a specific redirect mechanism.
- It confirms the protected content is absent.
- It verifies that the return path is encoded and preserved.
2) Signed-in user can reach the protected route
This catches overzealous guards that block valid sessions.
test('authenticated users can open dashboard', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('session', 'valid');
});
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
If your app uses cookies instead of localStorage, set them before navigation. The key point is to control auth state in the test, not through manual UI setup.
3) Bad URLs show a real 404 state
404 testing is easy to overfit. The useful check is that the app acknowledges the route is unknown and does not silently render a partial page.
test('unknown client-side route shows not found page', async ({ page }) => {
await page.goto('/this-route-does-not-exist');
await expect(page.getByRole('heading', { name: 'Page not found' })).toBeVisible();
await expect(page).toHaveURL(/\/this-route-does-not-exist$/);
});
That last assertion matters. A lot of SPA 404 bugs are not failures to render a component, they are failures to keep the URL stable after the catch-all route takes over.
4) Expired session redirects once, not forever
Redirect loops usually happen when the app treats the login page as protected, or when it keeps retrying a stale session on every navigation. The test should prove the page settles.
A lightweight way to detect a loop is to count navigations or watch for repeated URL changes in a short window.
test('expired session does not cause a redirect loop', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('session', 'expired');
});
const urls: string[] = [];
page.on('framenavigated', frame => {
if (frame === page.mainFrame()) urls.push(frame.url());
});
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard/);
await page.waitForTimeout(500);
expect(urls.filter(u => u.includes('/login')).length).toBeLessThan(3);
});
This is intentionally simple. You are not proving the absolute absence of all loops. You are catching the regression class where the app visibly bounces between the same routes.
If a redirect loop is intermittent, keep the test focused on the URL sequence and the visible page, not on arbitrary timing guesses.
5) Login returns the user to the original route
This is the behavioral contract most users notice.
test('login returns user to the originally requested page', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard/);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
If this breaks, the app may still be “working” from the router’s perspective, but the user experience is wrong.
Debugging failures without guessing
When a test fails, identify which of the three behaviors broke.
If protected content appears before redirect
Check whether the guard renders children before auth state is known. A loading state may be needed while the session is resolved.
If 404 tests pass locally but fail in CI
Verify the test server is configured for client-side routing. The server must serve the SPA shell for unknown routes, otherwise direct navigation to /this-route-does-not-exist becomes a server 404 instead of an app 404.
For dev servers, this is often a history API fallback or rewrite rule. The exact setting depends on the tooling, but the principle is the same: the browser should receive the app shell for deep links.
If redirect loops appear only on expired sessions
Look for one of these patterns:
- the login page itself triggers auth refresh logic
- the guard runs before session state is initialized
- stale auth state is cached in both memory and storage
- redirects ignore a
returnTovalue and keep recalculating the destination
If tests are flaky around navigation
Prefer page-state assertions over fixed sleeps. Use toHaveURL, role-based selectors, and explicit page-ready conditions. Reserve timeouts for diagnostics, not as the primary sync mechanism.
Keeping the project reproducible
A test project becomes reusable when the setup is obvious and the data is controlled.
Good reproducibility rules
- Seed auth state inside the test or a dedicated helper.
- Make routes deterministic, with one protected page, one 404 page, and one login page.
- Keep selectors semantic, such as role and heading text.
- Run the same tests in local and CI environments.
- Reset session state between tests.
Helpful CI snippet
name: spa-navigation-tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test
If these tests fail only in CI, the issue is usually environment setup, not browser automation itself. Look at routing rewrites, base URL configuration, and auth initialization order.
What to skip
This project is not the best fit if you need full identity-provider coverage, multi-factor flows, or cross-browser visual verification of every layout state. In those cases, add more layers, but keep this project as the fast signal for route behavior.
It is also not the right place to validate every API response behind the auth layer. That belongs in API tests or contract tests. The browser project should stay focused on navigation outcomes.
A compact decision rule
Use this project structure when:
- your app depends on client-side routing,
- auth state changes the route a user sees,
- deep links must work after refresh,
- and your team has had at least one regression involving guards, 404s, or redirects.
If you only need to check that a page component renders, component tests may be enough. If you need to verify real browser navigation and user-visible outcomes, this project pays for itself quickly because the assertions map directly to failures users experience.
FAQ
How do I test SPA 404 handling if the server returns 404 first?
That is a server configuration issue, not just a test issue. Configure the test server to serve the SPA shell for unknown client-side routes, then let the app render its own not-found screen.
Should I assert on window.location or page text?
Use both, but for different reasons. The URL proves navigation, and the visible heading proves the user sees the right state. Avoid brittle assertions on internal component structure.
How can I detect redirect loops reliably?
Watch the main frame URL changes and confirm the page settles on one destination. Do not use a long sleep as the primary proof. A loop is a navigation pattern, so the assertion should inspect navigation.
What if auth depends on cookies, not localStorage?
Set cookies before navigation in the test. The principle is the same, control the session state externally so the page starts in a known condition.
Do I need separate tests for public, protected, and missing routes?
Yes. They fail for different reasons, and a single test usually makes the failure harder to interpret.
Why not test router internals directly?
Because the user does not care about the internal router object. The user cares about the final page, URL, and whether navigation settles correctly. Testing those outcomes makes the project more resilient to framework changes.