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

ToolPurpose
VitestTest runner — native ESM, Vite-integrated, fast HMR
@testing-library/reactComponent rendering and DOM queries
@testing-library/jest-domCustom matchers (toBeInTheDocument, toHaveTextContent, etc.)
@testing-library/user-eventRealistic user interaction simulation
jsdomBrowser environment for Node.js
@vitest/coverage-v8Code coverage via V8’s built-in instrumentation

Server-Side Testing (Express/Node.js)

ToolPurpose
VitestSame 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-event

2. 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

  1. Pure business logic — Highest value, easiest to test. Extract functions from hooks/components into src/lib/ modules.
  2. Data transformation — ERP field normalization, date parsing, status computation, conversion code handling.
  3. Presentational components — Components that take props and render JSX. No providers or mocking needed.
  4. Server services — Pure functions in Express services (transformation logic, hash computation, record classification).
  5. 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.ts
  • src/components/projects/__tests__/ProjectCard.test.tsx
  • server/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):

  1. getByRole — accessible name queries (buttons, headings)
  2. getByText — visible text content
  3. getByTestId — last resort, requires adding data-testid attributes
  4. container.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:

MetricThreshold
Statements60%
Branches55%
Functions60%
Lines60%

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 build

Pipeline 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

IssueSolution
CircleAlert not found in lucide-reactUse AlertCircle instead (version-dependent)
toLocaleDateString renders differently in jsdom vs browserAssert on DOM structure or use container.textContent instead of exact formatted strings
Triple-slash /// <reference> fails ESLintRemove it — vitest/config types resolve via tsconfig.json
Date-only ISO strings produce off-by-oneUse T00:00:00 suffix or range assertions
react-refresh/only-export-components warningMove pure functions to src/lib/, not hook files
noUnusedLocals causes build failuresRemove unused imports immediately; TypeScript strict mode is enforced
Coverage thresholds fail with too-broad includeOnly include files that have tests; expand as coverage grows
Vitest exits code 1 with no test filesAdd passWithNoTests: true to config while bootstrapping
CJS require() in server ESM testsUse 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:

MetricValue
Test files6
Total tests76
Statements coverage91.5%
Branch coverage84.9%
Function coverage100%
Line coverage95.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:

MetricValue
Test files2 (server)
Total tests90
FocusPure 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


Last updated: February 2026