Table of Contents

Vite + Vitest Testing Strategy for React SPA

1. Context & Goals

This document defines best practices for testing a React SPA built with Vite. The application is UI-focused, API-driven, and optimized for fast rendering of large datasets (inventory-style screens).

Goals

Align testing with the Vite build ecosystem

Ensure fast, reliable, and deterministic tests

Enable auth token reuse across Unit, Integration, and E2E tests

Support CI/CD quality gates

React + TypeScript + Vite

├── Unit Tests → Vitest + React Testing Library
├── Integration Tests → Vitest + RTL + MSW
├── API Contract → MSW
├── Hybrid E2E → Playwright + MSW
└── Full E2E → Playwright (real backend)

4. Test Classification & Scope

Example Code Snippets (Vite + Vitest + React)

4.0.1 Main App & Layout Wiring

src/app/App.tsx
import { BrowserRouter } from 'react-router-dom';
import RoutesConfig from './routes';

export default function App() {
return (
<BrowserRouter>
<RoutesConfig />
</BrowserRouter>
);
}
src/layouts/MainLayout/MainLayout.tsx
import { Outlet } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { Menu } from '@/components/layout/Navigation';

export const MainLayout = () ⇒ (
<>
<Header />
<Menu />
<main>
<Outlet />
</main>
<Footer />
</>
);

src/app/routes.tsx
import { Routes, Route } from 'react-router-dom';
import { MainLayout } from '@/layouts/MainLayout';
import HomePage from '@/pages/Home/HomePage';
import InventoryPage from '@/pages/Inventory/InventoryPage';

export default function RoutesConfig() {
return (
<Routes>
<Route element={<MainLayout />}>
<Route path=“/” element={<HomePage />} />
<Route path=“/inventory” element={<InventoryPage />} />
</Route>
</Routes>
);
} ===== 4. Test Classification & Scope ===== ==== 4.1 Unit Tests ==== Purpose: Validate isolated UI logic What to test - Presentational components - Hooks - Utility functions What NOT to test - API calls - Auth flows Tools - Vitest - React Testing Library ==== 4.2 Integration Tests ==== Purpose: Validate UI + API interaction What to test - API data rendering - Error states - Token attached to requests Tools - Vitest - RTL - MSW (Mock Service Worker) ==== 4.3 API Contract Tests (via MSW) ==== Validate request headers, query params, payloads Ensure frontend-backend contract stability ==== 4.4 Hybrid E2E Tests ==== Purpose: Fast, stable E2E without backend dependency Characteristics - Real browser (Playwright) - Real authentication token - Business APIs mocked via MSW ==== 4.5 Full E2E Tests ==== Purpose: Validate critical production flows Real backend Real authentication Minimal coverage (smoke tests only) ===== 5. Authentication Token Strategy (Single Source of Truth) ===== ==== Design Principles ==== Fetch auth token once Reuse across all test layers No hardcoding inside tests ==== Flow ==== Auth Token

├─ Generated via Playwright global setup
├─ Stored in env / storageState
├─ Consumed by MSW handlers
└─ Used by Vitest integration tests ==== Benefits ==== Consistent auth behavior Faster test execution Reduced flakiness ===== 6. Project Directory Structure ===== The following directory structure is the recommended standard for this React + TypeScript + Vite SPA. It cleanly separates app bootstrapping, layouts, pages, features, services, and tests, and scales well for large inventory-style applications. src/
├── app/
│ ├── App.tsx
│ ├── routes.tsx
│ └── providers.tsx

├── layouts/
│ └── MainLayout/
│ ├── MainLayout.tsx
│ └── index.ts

├── components/
│ ├── layout/
│ │ ├── Header/
│ │ │ ├── Header.tsx
│ │ │ └── index.ts
│ │ ├── Footer/
│ │ │ ├── Footer.tsx
│ │ │ └── index.ts
│ │ └── Navigation/
│ │ ├── Menu.tsx
│ │ └── index.ts
│ │
│ └── common/
│ ├── Button/
│ ├── Input/
│ ├── Loader/
│ └── Modal/

├── pages/
│ ├── Home/
│ │ ├── HomePage.tsx
│ │ └── index.ts
│ │
│ ├── Inventory/
│ │ ├── InventoryPage.tsx
│ │ ├── InventoryTable.tsx
│ │ └── index.ts
│ │
│ └── NotFound/
│ └── NotFoundPage.tsx

├── features/
│ └── inventory/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── types.ts

├── services/
│ ├── apiClient.ts
│ └── inventoryService.ts

├── hooks/
│ └── useDebounce.ts

├── types/
│ └── common.types.ts

├── utils/
│ └── formatters.ts

├── styles/
│ └── global.css

├── tests/
│ ├── unit/
│ ├── integration/
│ └── msw/

├── main.tsx
└── vite-env.d.ts ==== Structural Guidelines ==== app/ – Application bootstrap, routing, and providers layouts/ – Shared page layouts (Header, Footer, Navigation) components/ – Reusable UI components pages/ – Route-level components features/ – Feature-scoped logic for scalability services/API and backend interaction layer tests/ – Unit, integration, and MSW setup ===== 7. CI/CD Best Practices ===== ==== Pipeline Order ==== Lint + Type Check Unit Tests (Vitest) Integration Tests (Vitest + MSW) Hybrid E2E (Playwright) Optional Full E2E (nightly) ==== Failure Handling ==== Fail pipeline on any test failure Upload Playwright traces/screenshots Publish test reports ===== 8. Performance Considerations ===== Prefer client-side rendering (SPA) Use virtualization for large datasets Avoid SSR unless SEO is required Testing should not depend on real backend performance ===== 10. Tooling Configuration (Vitest + RTL) ===== ==== Vitest Configuration ====
vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/tests/setupTests.ts',
globals: true,
css: true,
coverage: {
reporter: ['text', 'html'],
},
},
});

Test Setup

src/tests/setupTests.ts
import '@testing-library/jest-dom'; ===== 11. Unit Test Example (Vitest + RTL) =====
src/tests/unit/Header.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Header } from '@/components/layout/Header';

describe('Header', () ⇒ {
it('renders application title', () ⇒ {
render(<Header />);
expect(screen.getByText(/inventory/i)).toBeInTheDocument();
});
});

12. Integration Test Example (Vitest + RTL + MSW)

MSW Handler

src/tests/msw/handlers.ts
import { rest } from 'msw';

export const handlers = [
rest.get('/api/inventory', (req, res, ctx) ⇒ {
const token = req.headers.get('Authorization');
if (!token) return res(ctx.status(401));

return res(
ctx.status(200),
ctx.json([{ id: 1, name: 'Item A' }])
);
}),
]; ==== MSW Server Setup ====
src/tests/msw/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(…handlers);

src/tests/setupTests.ts
import '@testing-library/jest-dom';
import { server } from './msw/server';

beforeAll1);
afterEach2);
afterAll3); ==== Integration Test ====
src/tests/integration/InventoryPage.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import InventoryPage from '@/pages/Inventory/InventoryPage';

it('renders inventory data from API', async () ⇒ {
render(<InventoryPage />);

await waitFor4).toBeInTheDocument();
});
});

10. Final Recommendation

Area Choice
Build Tool Vite
Unit Tests Vitest
API Mocking MSW
UI Testing RTL
E2E Playwright
Auth Strategy Shared Token

11. API Client with Token Interceptor

Centralized API client ensures consistent auth handling, easy mocking, and reuse across Unit, Integration, and E2E tests.

src/services/apiClient.ts
import axios from 'axios';

const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});

apiClient.interceptors.request.use5).toBeVisible();
}); ===== 13. CI/CD Pipeline Snippets ===== ==== GitHub Actions ==== # .github/workflows/frontend-ci.yml
name: Frontend CI

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm run test:unit
- run: npm run test:integration
- run: npx playwright install –with-deps
- run: npm run test:e2e ==== GitLab CI ==== frontend_tests:
image: node:18
stage: test
script:
- npm ci
- npm run test:unit
- npm run test:integration
- npx playwright install –with-deps
- npm run test:e2e
artifacts:
when: always
paths:
- playwright-report/ ===== 14. Token Refresh & Expiry Handling ===== ==== Design Principles ==== Never refresh tokens inside UI components Centralize refresh logic in API client Retry failed requests transparently
src/services/apiClient.ts (extended)
import axios from 'axios';

let isRefreshing = false;
let queue: any[] = [];

const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});

apiClient.interceptors.response.use(
(res) ⇒ res,
async (error) ⇒ {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise6);
}

originalRequest._retry = true;
isRefreshing = true;

const refreshToken = sessionStorage.getItem('refresh_token');
const response = await axios.post('/auth/refresh', { refreshToken });

sessionStorage.setItem('auth_token', response.data.accessToken);
queue.forEach7);
queue = [];
isRefreshing = false;

return apiClient(originalRequest);
}

return Promise.reject(error);
}
);

export default apiClient;

15. API Contract Versioning Strategy

Versioning Rules

APIs must be versioned (URI or header based)

Breaking changes require new version

MSW enforces contract compatibility

src/tests/msw/handlers.ts
rest.get('/api/v1/inventory', (req, res, ctx) ⇒ {
return res(ctx.json([{ id: 1, name: 'Item V1' }]));
});

rest.get('/api/v2/inventory', (req, res, ctx) ⇒ {
return res(ctx.json([{ id: 1, name: 'Item V2', stock: 100 }]));
}); ===== 16. Performance Testing (Lighthouse + Playwright) ===== ==== Lighthouse CI ==== npm install -D @lhci/cli
lighthouserc.json
{
“ci”: {
“collect”: {
“url”: [“http://localhost:5173”],
“startServerCommand”: “npm run preview”
},
“assert”: {
“assertions”: {
“categories:performance”: [“warn”, { “minScore”: 0.8 }]
}
}
}
}

Playwright Performance Smoke

e2e/performance.spec.ts
import { test } from '@playwright/test';

test('home page loads under 2s', async ({ page }) ⇒ {
const start = Date.now();
await page.goto('/');
const duration = Date.now() - start;
expect(duration).toBeLessThan(2000);
}); ===== 17. Starter Vite Repository Blueprint ===== ==== Scripts ==== {
“scripts”: {
“dev”: “vite”,
“build”: “vite build”,
“preview”: “vite preview”,
“test:unit”: “vitest”,
“test:integration”: “vitest run”,
“test:e2e”: “playwright test”,
“perf”: “lhci autorun”
}
} ==== Environment Files ==== .env
.env.test
.env.e2e ==== Ready-to-Use ==== Vite + React + TS Vitest + MSW Playwright (auth + hybrid) CI/CD ready Performance gates included

1)
) ⇒ server.listen(
2)
) ⇒ server.resetHandlers(
3)
) ⇒ server.close(
4)
) ⇒ {
expect(screen.getByText('Item A'
5)
config) ⇒ {
const token = sessionStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

export default apiClient; src/services/inventoryService.ts
import apiClient from './apiClient';

export const fetchInventory = async () ⇒ {
const response = await apiClient.get('/inventory');
return response.data;
}; ===== 12. Playwright Global Auth + Hybrid MSW E2E ===== ==== Global Authentication Setup ====
e2e/playwright/global-setup.ts
import { chromium } from '@playwright/test';

export default async () ⇒ {
const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto(process.env.AUTH_URL!);
await page.fill('#username', process.env.E2E_USER!);
await page.fill('#password', process.env.E2E_PASSWORD!);
await page.click('button[type=submit]');

await page.waitForURL('/home');
await page.context().storageState({ path: 'e2e/auth.json' });

await browser.close();
}; e2e/playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
globalSetup: './e2e/playwright/global-setup.ts',
use: {
storageState: 'e2e/auth.json',
},
}); ==== Hybrid E2E Test ====
e2e/inventory.hybrid.spec.ts
import { test, expect } from '@playwright/test';

test('inventory loads with mocked API', async ({ page }) ⇒ {
await page.route('
/api/inventory', async (route) ⇒ {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock Item' }]),
});
});

await page.goto('/inventory');
await expect(page.getByText('Mock Item'
6)
resolve) ⇒ queue.push(resolve
7)
cb) ⇒ cb(