How to Test Single-Page Apps
SPAs move routing, state, and rendering into the browser, which breaks assumptions that older test approaches relied on. Here's what changes and how to test them reliably.
Single-page applications — React, Vue, Angular, Svelte — handle routing, state, and rendering on the client. The server sends one HTML shell and a bundle of JavaScript; everything after that happens in the browser. That makes SPAs fast and app-like, and it breaks several assumptions that test tooling built for server-rendered pages relied on. Tests that were reliable against a traditional multi-page site turn flaky against a SPA for reasons that aren't obvious.
What changes with a SPA
- Navigation doesn't reload the page. Clicking a link changes the URL and swaps the view, but there's no full document load. Tests that wait for a "page loaded" or "navigation complete" event wait forever, or fire immediately and then act on the old view.
- Content appears asynchronously, in stages. The shell renders, then a loading state, then data arrives and the real content renders — often in several waves as different components resolve their own requests. The element you want to interact with may not exist for a few hundred milliseconds after the "page" appears to be there.
- State lives in memory, not in the document. A server-rendered app persists a lot of state in the URL or in server sessions. A SPA often holds it in a client-side store that a refresh wipes. Deep-linking into a mid-flow state — step 3 of a wizard, a filtered table — may not restore the way a tester expects, or may not be supported at all.
- The DOM is volatile. Frameworks re-render freely in response to state changes. An element reference or a query result captured a moment ago can be stale by the time the test uses it, because the framework replaced that part of the tree.
- Routing has guards and lazy loading. Route changes can trigger auth checks, redirects, and on-demand bundle downloads, each of which adds a pause the test has to account for.
How to test them reliably
- Wait on application state, never on time or on page-load events. Wait for the specific element to be present and interactable (visible, enabled, not covered). Wait for the network request that populates the view to resolve. Never
sleep()— the app's timing depends on latency and data size, so a fixed wait is both slow and flaky. This is the single biggest source of flaky SPA tests. - Select by role or intentional test IDs, never by DOM structure. Frameworks re-render and restructure the tree constantly; a
data-testidthe team commits to keeping, or an accessible role and name, survives that. Adiv:nth-child(3) > spandoes not. - Test client-side routing explicitly. Navigate the way a user does — click the link — and then verify both that the URL changed and that the expected view rendered. A URL that updates without the view following is a real and common SPA bug.
- Check refresh and deep-link behaviour. Reload the page partway through a flow and confirm the app recovers gracefully — restores the state, or redirects somewhere sensible — rather than showing a blank screen or an error. Do the same for pasting a deep link into a fresh tab.
- Account for the loading states. Assert that spinners and skeletons appear and then disappear, not just that the final content shows up. A stuck loading state is a bug your "wait for content" logic might mask.
- Handle auth as a first-class case. Token expiry, silent refresh, and route guards all behave differently in a SPA than in a server-rendered app — a token can expire mid-session with no page load to trigger a redirect. See testing authentication flows.
Where autonomous testing helps
Most of the friction above comes from a scripted test carrying assumptions about timing and structure that a SPA violates. An autonomous agent doesn't carry those assumptions. It navigates client-side routes the way a user does, waits for the app to reach a stable rendered state before acting, and re-derives how to reach and interact with each element on every run rather than replaying stored selectors. Re-renders, async content, and lazy-loaded routes are handled because the agent is reacting to the app's actual state, not a recording of it.
See how to test a React app end to end, and the framework-specific equivalents for Next.js, Vue, and Angular — each covers the gotchas particular to that stack.
Bottom line
Testing a SPA reliably comes down to a few disciplines: wait on state instead of on loads, select by role or test ID instead of by structure, test client-side routing and refresh behaviour explicitly, verify loading states resolve, and treat auth as its own problem. Tools that assume server-rendered navigation will fight you the whole way; ones that model the app as a user actually experiences it won't. The end-to-end testing guide has the full context.