Web App Testing Best Practices
Standards and patterns for automated testing in PSI’s React web applications. Established with the psi-explorer-web project (February 2026) and applicable to all PSI web apps.
Technology Stack
| Tool | Purpose |
|---|---|
| Vitest | Test runner — native ESM, Vite-integrated, fast HMR |
| @testing-library/react | Component rendering and DOM queries |
| @testing-library/jest-dom | Custom matchers (toBeInTheDocument, toHaveTextContent, etc.) |
| @testing-library/user-event | Realistic user interaction simulation |
| jsdom | Browser environment for Node.js |
| @vitest/coverage-v8 | Code coverage via V8’s built-in instrumentation |
Server-Side Testing (Express/Node.js)
| Tool | Purpose |
|---|---|
| Vitest | Same test runner for consistency across client/server |
| supertest (optional) | HTTP endpoint integration testing |
Project Setup
TypeScript Projects
1. Install Dependencies
npm install --save-dev vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event2. Configuration Files
vitest.config.ts (project root) — Merges with existing Vite config to inherit plugins and path aliases:
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false,
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'lcov'],
reportsDirectory: './coverage',
include: [/* list files with tests */],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
thresholds: {
statements: 60,
branches: 55,
functions: 60,
lines: 60,
},
},
},
})
);src/test/setup.ts — Registers jest-dom matchers globally:
import '@testing-library/jest-dom/vitest';tsconfig.json — Add vitest globals to compiler options:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}tsconfig.node.json — Include the vitest config:
{
"include": ["vite.config.ts", "vitest.config.ts"]
}JavaScript Projects
For projects using plain JavaScript (JSX) instead of TypeScript:
1. Install Dependencies
Same as TypeScript, but no @types/* packages needed.
2. Configuration Files
vitest.config.js — Same structure, using .js extension:
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.js'],
include: ['src/**/*.test.{js,jsx}'],
passWithNoTests: true,
css: false,
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
reportsDirectory: './coverage',
exclude: ['src/**/*.test.{js,jsx}', 'src/test/**'],
thresholds: {
statements: 60,
branches: 55,
functions: 60,
lines: 60,
},
},
},
})
);src/test/setup.js:
import '@testing-library/jest-dom/vitest';No tsconfig.json changes needed.
Server-Side Setup (Express/Node.js)
Server projects get their own Vitest config without jsdom (Node environment):
server/vitest.config.js:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['**/__tests__/**/*.test.{js,mjs}', '**/*.test.{js,mjs}'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
reportsDirectory: './coverage',
exclude: ['**/__tests__/**', '**/*.test.{js,mjs}'],
thresholds: {
statements: 60,
branches: 55,
functions: 60,
lines: 60,
},
},
},
});3. Package.json Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}4. ESLint Override for Test Files
overrides: [
{
files: ['**/*.test.ts', '**/*.test.tsx'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
],5. Gitignore
Add coverage to .gitignore to exclude generated reports.
Architecture: What to Test
Testing Pyramid for PSI Web Apps
┌──────────â”
│ E2E │ ↠Future: Playwright (not yet implemented)
├──────────┤
╱│ Component │╲ ↠@testing-library/react
╱ ├──────────┤ ╲
╱ │ Unit │ ╲ ↠Pure function tests (highest ROI)
╱ └──────────┘ ╲
╱ Static Analysis ╲ ↠TypeScript strict + ESLint
└───────────────────────┘
Priority Order
- Pure business logic — Highest value, easiest to test. Extract functions from hooks/components into
src/lib/modules. - Data transformation — ERP field normalization, date parsing, status computation, conversion code handling.
- Presentational components — Components that take props and render JSX. No providers or mocking needed.
- Server services — Pure functions in Express services (transformation logic, hash computation, record classification).
- Hook integration — Test hooks with mock API responses (lower priority, higher complexity).
What NOT to Test (Initially)
- Third-party library behavior (React Query caching, Tailwind classes rendering)
- Simple pass-through components
- CSS/styling details (use visual regression tools instead)
- API integration (use contract tests or E2E instead)
- Database queries directly (test the logic around them, mock the pool)
Patterns
Extract Pure Functions for Testability
PSI web apps consume ERP data with inconsistent field names. Business logic for normalization, health computation, and date math should live in src/lib/ modules (client) or be exported from services (server) — not buried inside hooks, components, or route handlers.
Why src/lib/ instead of exporting from hooks?
ESLint’s react-refresh/only-export-components rule warns when non-component functions are exported from hook files. Separating pure logic into src/lib/ avoids this and makes functions independently testable.
Client structure:
src/
├── lib/
│ ├── projectUtils.ts ↠Pure functions (getField, computeHealth, etc.)
│ └── __tests__/
│ └── projectUtils.test.ts
├── hooks/
│ └── useProjects.ts ↠Imports from ../lib/projectUtils
└── components/
└── projects/
├── ProjectCard.tsx
└── __tests__/
└── ProjectCard.test.tsx
Server structure (Express.js):
server/
├── services/
│ ├── exportPipelineService.js ↠Pure functions exported alongside async ones
│ ├── syncSnapshotService.js ↠computeRecordHash, classifyRecords
│ └── __tests__/
│ ├── exportPipelineService.test.js
│ └── syncSnapshotService.test.js
For server services that mix pure functions with database-dependent ones, export the pure functions and test them directly. No mocking needed for functions like autoConvert(), splitMultivalue(), or computeRecordHash().
Test File Location
Place test files in __tests__/ directories adjacent to the code they test:
src/lib/__tests__/projectUtils.test.tssrc/components/projects/__tests__/ProjectCard.test.tsxserver/services/__tests__/exportPipelineService.test.js
Test Fixtures / Factories
Create src/test/fixtures.ts (or .js) with builder functions that return valid objects with sensible defaults:
import type { Project } from '../types/project';
export function buildProject(overrides: Partial<Project> = {}): Project {
return {
id: 'P-1001',
jobNumber: '1234',
customerName: 'Acme Corp',
health: 'on-track',
// ... all required fields with sensible defaults
...overrides,
};
}This pattern lets each test override only the fields relevant to its assertion, keeping tests readable.
Faking Time for Date-Dependent Logic
Use vi.useFakeTimers() + vi.setSystemTime() for date math tests. Use local-time constructors to avoid timezone offset issues:
beforeEach(() => {
vi.useFakeTimers();
// Use local midnight — avoids UTC offset issues with setHours(0,0,0,0)
vi.setSystemTime(new Date(2026, 1, 6, 0, 0, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});Known gotcha: Date-only ISO strings like '2026-02-06' are parsed as UTC midnight by new Date(), while new Date() returns local time. When code uses setHours(0,0,0,0) on both, a timezone behind UTC will see an off-by-one. Use '2026-02-06T00:00:00' (with time component, no Z) for local-time parsing, or test with range assertions.
Component Tests — No Providers Needed for Presentational Components
PSI components follow the Page Orchestrator pattern: hooks live in the page component, and child components receive all data as props. This means most component tests need no QueryClientProvider or mocking:
import { render, screen } from '@testing-library/react';
import { ProjectHealthBadge } from '../ProjectHealthBadge';
it('renders correct label', () => {
render(<ProjectHealthBadge health="delayed" />);
expect(screen.getByText('Delayed')).toBeInTheDocument();
});Testing DOM Queries
Prefer queries in this order (per Testing Library guidelines):
getByRole— accessible name queries (buttons, headings)getByText— visible text contentgetByTestId— last resort, requires addingdata-testidattributescontainer.querySelector— when CSS class assertions are needed
Parameterized Tests with it.each
Use it.each for enumerating all variants of a type:
const healthLabels: [ProjectHealth, string][] = [
['on-track', 'On Track'],
['at-risk', 'At Risk'],
['delayed', 'Delayed'],
];
it.each(healthLabels)('renders label for %s', (health, label) => {
render(<ProjectHealthBadge health={health} />);
expect(screen.getByText(label)).toBeInTheDocument();
});Server-Side Pure Function Tests
The highest-value server tests target pure transformation functions. These have zero dependencies and high business impact:
import { describe, it, expect } from 'vitest';
import { autoConvert, splitMultivalue, transformRecord } from '../exportPipelineService.js';
describe('autoConvert', () => {
it('converts UniData date to ISO', () => {
// 20109 days since 12/31/1967 = 2023-01-20
expect(autoConvert('20109', 'D2/')).toBe('2023-01-20');
});
it('converts masked decimal MD2', () => {
expect(autoConvert('73518087', 'MD2')).toBe(735180.87);
});
it('converts boolean MY', () => {
expect(autoConvert('Y', 'MY')).toBe(true);
expect(autoConvert('N', 'MY')).toBe(false);
});
});Coverage Strategy
Scoped Coverage
Only include files that have corresponding tests in the coverage report. This prevents untested files from dragging down aggregate metrics and gives an accurate picture of tested code quality.
// vitest.config.ts
coverage: {
include: [
'src/lib/**/*.ts',
'src/components/projects/ProjectCard.tsx',
// ... list specific tested files
],
}As you add tests for more files, expand the include list. This provides a ratchet effect — coverage can only go up.
Thresholds
Starting thresholds for PSI web apps:
| Metric | Threshold |
|---|---|
| Statements | 60% |
| Branches | 55% |
| Functions | 60% |
| Lines | 60% |
These are enforced in CI — the build fails if coverage drops below thresholds.
CI Integration
Add test steps before the build step in the GitHub Actions workflow:
- name: Install server dependencies
run: cd server && npm ci --omit=optional
- name: Test server
run: cd server && npm test
- name: Install client dependencies
run: cd client && npm ci
- name: Test client
run: cd client && npm test
- name: Build client
run: cd client && npm run buildPipeline Order
Install → Test (server) → Test (client) → Build → Deploy
Tests run before build to fail fast on logic errors. Server tests run first since they’re typically faster (no jsdom overhead).
Common Gotchas
| Issue | Solution |
|---|---|
CircleAlert not found in lucide-react | Use AlertCircle instead (version-dependent) |
toLocaleDateString renders differently in jsdom vs browser | Assert on DOM structure or use container.textContent instead of exact formatted strings |
Triple-slash /// <reference> fails ESLint | Remove it — vitest/config types resolve via tsconfig.json |
| Date-only ISO strings produce off-by-one | Use T00:00:00 suffix or range assertions |
react-refresh/only-export-components warning | Move pure functions to src/lib/, not hook files |
noUnusedLocals causes build failures | Remove unused imports immediately; TypeScript strict mode is enforced |
Coverage thresholds fail with too-broad include | Only include files that have tests; expand as coverage grows |
| Vitest exits code 1 with no test files | Add passWithNoTests: true to config while bootstrapping |
CJS require() in server ESM tests | Use import syntax in test files; Vitest handles CJS↔ESM interop |
Reference Implementations
psi-explorer-web (TypeScript)
The psi-explorer-web project is the original reference implementation:
| Metric | Value |
|---|---|
| Test files | 6 |
| Total tests | 76 |
| Statements coverage | 91.5% |
| Branch coverage | 84.9% |
| Function coverage | 100% |
| Line coverage | 95.7% |
src/
├── lib/
│ ├── projectUtils.ts ↠8 extracted pure functions
│ └── __tests__/
│ └── projectUtils.test.ts ↠45 unit tests
├── test/
│ ├── setup.ts ↠jest-dom registration
│ └── fixtures.ts ↠buildProject(), buildMaterialReadiness()
└── components/projects/__tests__/
├── ProjectHealthBadge.test.tsx ↠7 tests
├── ProjectPhaseBadge.test.tsx ↠8 tests
├── MaterialReadinessBar.test.tsx ↠6 tests
├── ProjectCard.test.tsx ↠6 tests
└── ProjectDashboard.test.tsx ↠4 tests
erp-migration-tool (JavaScript + Server)
The erp-migration-tool demonstrates the server-side testing pattern:
| Metric | Value |
|---|---|
| Test files | 2 (server) |
| Total tests | 90 |
| Focus | Pure transformation functions |
server/
├── services/
│ ├── exportPipelineService.js ↠autoConvert, splitMultivalue, extractFieldValue, etc.
│ ├── syncSnapshotService.js ↠computeRecordHash, classifyRecords
│ └── __tests__/
│ ├── exportPipelineService.test.js ↠79 tests
│ └── syncSnapshotService.test.js ↠11 tests
├── vitest.config.js
client/
├── vitest.config.js
├── src/test/setup.js
Related Documentation
- Applications Overview — All PSI web apps
- deploy-to-azure — Azure deployment with CI/CD
- dev-setup — Developer environment setup
Last updated: February 2026