Testing TanStack Router + Query apps in the real browser
Real-Browser Integration Testing for TanStack Router and Query: A TWD Implementation Guide
Current Situation Analysis
Modern TanStack applications rely on a tightly coupled stack: the Router manages navigation and data loading via loaders, while React Query handles caching, synchronization, and server state. Testing this stack in isolation using traditional tools like React Testing Library (RTL) introduces significant friction.
The standard approach requires reconstructing the entire application context within jsdom. Developers must instantiate a MemoryRouter, wrap components in QueryClientProvider and RouterProvider, and configure MSW handlers for every loader endpoint. This results in test files that often exceed the size of the components they verify. The boilerplate overhead obscures the actual test logic, and the jsdom environment lacks the fidelity of a real browser, leading to false positives regarding DOM interactions and network behavior.
This complexity is a primary reason teams abandon unit-style testing for TanStack apps in favor of end-to-end (E2E) tools like Playwright. While Playwright provides high fidelity, it introduces slow feedback loops due to browser boot times and requires a separate testing infrastructure. The industry lacks a middle ground that offers real-browser fidelity with the speed and developer experience of in-process testing.
WOW Moment: Key Findings
The twd-js ecosystem bridges this gap by executing tests directly within the running Vite development server. This approach eliminates the need for test harness reconstruction and leverages the actual application runtime.
The following comparison highlights the efficiency gains when adopting an in-browser testing strategy over traditional isolation methods:
| Approach | Setup Complexity | Browser Fidelity | Cache Management | Feedback Loop |
|---|---|---|---|---|
| RTL + MSW | High | Low (jsdom) |
Manual/Fragmented | Slow (Mock configuration) |
| Playwright | Medium | High | Real | Slow (Browser boot/teardown) |
| TWD Integration | Low | High (Real Browser) | Integrated | Instant (Dev server hot reload) |
Why this matters:
Running tests in the real browser context means your Router loaders, Query cache, and component tree behave exactly as they do in production. The twd-js plugin injects a test runner sidebar into the Vite dev server, allowing tests to import application modules directly. This enables immediate validation of data flow from loader to cache to UI without mocking the framework internals. The twd-relay plugin further extends this by exposing a WebSocket interface, enabling external automation and CI integration without headless browser overhead.
Core Solution
Implementing in-browser testing for TanStack applications involves configuring two Vite plugins, establishing a singleton QueryClient for test isolation, and writing tests that interact with the live DOM.
Step 1: Install Dependencies and Initialize
Install the core testing package and the relay bridge. The initialization command sets up the necessary service worker registration in the public directory.
npm install --save-dev twd-js twd-relay
npx twd-js init public --save
Step 2: Configure Vite Plugins
Add the testing plugins to your Vite configuration. These plugins are scoped to the development server using apply: 'serve', ensuring they do not impact production builds.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { router as tanstackRouterPlugin } from '@tanstack/router-plugin/vite';
import { twd as inBrowserRunner } from 'twd-js/vite-plugin';
import { twdRemote as testRelayBridge } from 'twd-relay/vite';
export default defineConfig({
plugins: [
tanstackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
react(),
inBrowserRunner({
testFilePattern: '/src/integration-tests/**/*.spec.ts',
sidebarPosition: 'right',
autoOpen: false
}),
testRelayBridge()
]
});
Architecture Rationale:
inBrowserRunner: Auto-discovers test files matching the pattern, mounts the test sidebar, and registers a service worker to intercept network requests. This service worker enables request mocking without modifying application code.testRelayBridge: Establishes a WebSocket connection to the Vite dev server. This allows external processes to trigger test suites and receive results as plain text, facilitating CI pipelines and AI-assisted development workflows.
Step 3: Export a Singleton QueryClient
TanStack Query caches data in memory. To ensure test isolation, you must be able to clear the cache between tests. This requires exporting the QueryClient instance as a module-level singleton rather than creating it inline within your application entry point.
// src/lib/query-infra.ts
import { QueryClient } from '@tanstack/react-query';
export const appQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: false
}
}
});
Use this singleton in your main.tsx or root component:
// src/main.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { appQueryClient } from '#/lib/query-infra';
// ... router setup ...
root.render(
<QueryClientProvider client={appQueryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
Step 4: Write Integration Tests
Test files run in the same browser tab as the application. They can import application utilities and interact with the real DOM.
// src/integration-tests/dashboard.spec.ts
import { twd, userActions, domSelectors } from 'twd-js';
import { describe, it, beforeEach } from 'twd-js/runner';
import { appQueryClient } from '#/lib/query-infra';
describe('Dashboard Module', () => {
beforeEach(() => {
twd.resetNetworkMocks();
appQueryClient.clear();
});
it('displays metrics after loader resolution', async () => {
twd.mockEndpoint('fetchMetrics', {
method: 'GET',
url: '/api/v1/metrics',
response: {
activeUsers: 150,
revenue: 42000,
growth: 12.5
},
status: 200
});
await twd.navigate('/dashboard');
await twd.awaitRequest('fetchMetrics');
const revenueDisplay = await domSelectors.findByText('$42,000');
twd.expect(revenueDisplay).toBeVisible();
});
it('handles empty state correctly', async () => {
twd.mockEndpoint('fetchMetrics', {
method: 'GET',
url: '/api/v1/metrics',
response: { activeUsers: 0, revenue: 0, growth: 0 },
status: 200
});
await twd.navigate('/dashboard');
await twd.awaitRequest('fetchMetrics');
const emptyMessage = await domSelectors.findByText('No data available');
twd.expect(emptyMessage).toHaveTextContent('No data available');
});
});
Implementation Details:
twd.navigate: Triggers a real Router navigation. This executes the route's loader function.twd.mockEndpoint: Registers a rule with the service worker. The mock intercepts the request initiated by the loader.twd.awaitRequest: Pauses execution until the mocked request completes, ensuring the Query cache is populated before assertions.domSelectors: Provides Testing Library-style queries scoped to the live DOM, enabling robust element selection.
Pitfall Guide
1. The SPA Cache Trap
Explanation: Single Page Application navigation reuses the existing JavaScript runtime. If Test A loads data into the Query cache, Test B navigating to the same route may retrieve the cached data instead of triggering a new request. This causes mock rules to timeout because no network request occurs.
Fix: Always clear the Query client in beforeEach. Ensure you are using the exported singleton.
beforeEach(() => {
appQueryClient.clear();
twd.resetNetworkMocks();
});
2. Inline QueryClient Instantiation
Explanation: Creating new QueryClient() directly in main.tsx prevents tests from accessing the instance to clear the cache. Tests will suffer from state leakage.
Fix: Refactor to export the client from a dedicated module and import it in both the application root and test files.
3. Mock Registration Order
Explanation: Registering a mock after navigation has started can result in race conditions where the loader fires before the service worker rule is active.
Fix: Always register mocks before calling twd.navigate.
// Correct order
twd.mockEndpoint('rule', { ... });
await twd.navigate('/route');
4. Service Worker Scope Issues
Explanation: If the service worker is not properly registered, network mocking will fail silently, and tests may hit real endpoints or fail to intercept.
Fix: Run npx twd-js init public --save to generate the service worker registration script. Verify the script is included in your HTML entry point.
5. Relay Connection Drops
Explanation: The WebSocket relay may disconnect if the Vite dev server restarts or if the terminal session is interrupted. This breaks external automation.
Fix: Restart the relay command (npx twd-relay run) after server restarts. Implement retry logic in CI scripts that consume the relay output.
6. Async Assertion Timing
Explanation: Using synchronous assertions on elements that render asynchronously can cause flaky tests.
Fix: Use async query methods like findByText or waitFor from domSelectors. Ensure await is used with navigation and request waiting utilities.
7. Missing Test File Pattern Match
Explanation: Tests may not appear in the sidebar if the file pattern in the Vite config does not match the test file extensions or directory structure.
Fix: Verify the testFilePattern regex in the Vite config matches your test file naming convention (e.g., /**/*.spec.ts).
Production Bundle
Action Checklist
- Install
twd-jsandtwd-relayas dev dependencies. - Run
npx twd-js init public --saveto set up service worker registration. - Add
twdandtwdRemoteplugins tovite.config.ts. - Refactor
QueryClientto a module-level singleton export. - Import the singleton in
main.tsxand test files. - Create test files matching the configured pattern with
.spec.tsextension. - Implement
beforeEachhooks to clear cache and reset mocks. - Run
npm run devand verify the test sidebar appears.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Fast feedback on component logic | TWD Integration | Real browser context with instant hot reload. No browser boot overhead. | Low (Dev dependency) |
| Cross-browser E2E validation | Playwright | Required for testing across multiple browser engines and OS environments. | Medium (CI infrastructure) |
| SSR/Node environment testing | RTL + MSW | Necessary for testing server-side rendering logic where browser APIs are unavailable. | Low (Standard tooling) |
| AI-Assisted Test Generation | TWD + Relay | Relay protocol enables autonomous agents to run tests and iterate on failures. | Low (Enables automation) |
Configuration Template
Use this template to integrate TWD into an existing TanStack project.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { router as tanstackRouterPlugin } from '@tanstack/router-plugin/vite';
import { twd as inBrowserRunner } from 'twd-js/vite-plugin';
import { twdRemote as testRelayBridge } from 'twd-relay/vite';
export default defineConfig({
plugins: [
tanstackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
react(),
inBrowserRunner({
testFilePattern: '/src/tests/**/*.spec.ts',
sidebarPosition: 'left',
autoOpen: true
}),
testRelayBridge()
]
});
Quick Start Guide
- Install Packages:
npm install --save-dev twd-js twd-relay - Initialize Service Worker:
npx twd-js init public --save - Update Vite Config:
Add the
twdandtwdRemoteplugins to yourvite.config.tsas shown in the configuration template. - Export QueryClient:
Create a singleton export for your
QueryClientand use it in your app root. - Run Development Server:
The test sidebar will appear automatically. Write your firstnpm run dev.spec.tsfile and observe results in real-time.
