← All docs

Testing Framework

Created: 2026-04-28 · Installed: 2026-04-29 · Status: fully configured — Vitest (15 unit tests) + Playwright (5 E2E smoke tests) running

Framework decision

LayerFrameworkWhy
UI components Vitest + React Testing Library Native to Vite/Next.js ecosystem, fast, co-located with source
E2E / user flows Playwright Best-in-class for Next.js; runs in a real browser; tests against local dev server
ORM / DB queries Vitest (integration mode) Same runner as UI tests; connects to a dedicated Neon test branch

Setup — UI tests (Vitest + RTL)

Step 1
Install
cd web
pnpm add -D vitest @vitejs/plugin-react jsdom \
  @testing-library/react @testing-library/user-event \
  @testing-library/jest-dom
Step 2
vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./test/setup.ts"],
  },
  resolve: {
    alias: { "@": path.resolve(__dirname, ".") },
  },
});
Step 3
test/setup.ts
import "@testing-library/jest-dom";
Step 4
Scripts in package.json
"test":          "vitest",
"test:ui":       "vitest --ui",
"test:coverage": "vitest run --coverage"

Setup — E2E tests (Playwright)

Step 1
Install
cd web
pnpm add -D @playwright/test
npx playwright install chromium
Step 2
playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  reporter: "html",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
  webServer: {
    command: "pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});
Step 3
Scripts in package.json
"test:e2e":    "playwright test",
"test:e2e:ui": "playwright test --ui"

Setup — DB / ORM tests (Vitest integration)

Step 1
Create a test database branch

In the Neon dashboard, create a branch named test from main. Add to .env:

TEST_DATABASE_URL="postgresql://..."
Step 2
Prisma test helper
// test/db.ts
import { PrismaNeon } from "@prisma/adapter-neon";
import { PrismaClient }  from "@prisma/client";
import { neon }          from "@neondatabase/serverless";

const sql = neon(process.env.TEST_DATABASE_URL!);
export const testDb = new PrismaClient({ adapter: new PrismaNeon(sql) });

export async function resetDb() {
  await testDb.$executeRawUnsafe(`
    TRUNCATE alert_user, alert, narrative, intent_probe_operator,
    intent_probe, comp_snapshot, comp, support, moat_score,
    moat_dimension, category_moat_dimension, category,
    demand_pressure_snapshot, convergence_snapshot,
    guest_profile, study, market_parcel, parcel, property,
    operator, permit_event, cup_event, anchor_visitor_stat,
    isochrone, market_anchor, market_county, anchor, county,
    market, subscription, "user"
    RESTART IDENTITY CASCADE
  `);
}

Test case inventory

RefineBar 6 cases
Renders with default query params (mode=scoping, catchment=60, acres=10-19)
Mode buttons switch active state on click and update URL
Acres toggle updates acres param
Budget toggle updates budget param
Catchment selector updates catchment param and preserves other params
Constraints dropdown renders without crashing
OpportunityBanner — PCI / DPI labels 8 cases
PCI 25 → label "Differentiated" · background var(--ocean)
PCI 50 → label "Recognizable" · background var(--sage)
PCI 72 → label "Converging" · background var(--gold)
PCI 95 → label "Indistinct" · background var(--rust)
DPI 100 → label "Demand exceeds supply"
DPI 70 → label "Approaching capacity"
DPI 50 → label "Low demand pressure"
Score numbers render as large numerals
DemandPressureSection 6 cases
Chart renders 12 month bars
Bars above KEYS value use sage gradient
Bars in 70–99 range use brass gradient
Bars below 70 use rust gradient
Supply ceiling dashed line renders at correct pixel position
Annual avg DPI calculation is correct (sum of monthly DPIs ÷ 12)
SupplyBanner 5 cases
Renders with catchment="60" by default
All three ring pills (30/60/90) are always visible
Active catchment pill is highlighted
Switching catchment pill updates the URL
Stat strip shows correct numbers for active catchment
MiniCard 8 cases
Renders parcel label and county
Zoning note shows green for "permitted outright"
Zoning note shows amber for "CUP required"
Zoning note shows red for "not permitted"
ADR ceiling and market average render side-by-side with divider
Saturated moats list shows top 3 by operator count
Close button (✕) calls onClose
"Full parcel detail →" button calls onExpand
RightPanel 10 cases
Renders parcel label, acres, county in header
Zoning section shows build-type specific note
Infrastructure section shows water / septic / power / road
Moat layout defaults to "A"
Clicking layout tab "B" switches to band view
Clicking layout tab "C" switches to split view
Clicking a moat cell populates the detail panel
Detail panel shows dimension name, definition and market count
Parcel score badge renders when score > 0
Close button calls onClose
MoatSection (S03 grid) 5 cases
Renders all 15 moat cards
Each card shows code, group, operator count and status label
Status accent color matches SAT_COLOR[status] CSS token (e.g. var(--rust) for "saturated")
Cells with count=0 render "—" not "0"
Legend renders all 5 status levels
E2E (Playwright) 12 cases
Homepage loads — build-type picker chips visible
Clicking a build-type chip navigates to dashboard with correct propertyType param
Dashboard loads at /dashboard/olympic-national-park-gateway
All 8 section headings visible in the page
RefineBar mode switch updates section content
Changing catchment in RefineBar updates supply stats
Clicking a parcel polygon on the map opens MiniCard
"Full parcel detail →" button opens RightPanel
Clicking backdrop closes RightPanel
Moat layout tabs A/B/C switch views in RightPanel
SectionNav click scrolls to correct section
Footer back-link navigates to homepage
DB / ORM integration (Vitest) 16 cases
Create Market with valid fields — returns record with id
Market creation fails without required name field
Unique constraint on Market slug — duplicate slug throws
Create Anchor → attach to Market via MarketAnchor many-to-many
Create MoatDimension with all 5 SaturationLevel enum values
Create Property → MoatScore with score 0–100
Create ConvergenceSnapshot — time-series query returns records in date order
DemandPressureSnapshot — dpiIndex stored as Decimal, retrievable
PostGIS Polygon field roundtrip — insert and read back Market.boundary
User with SubscriptionTier SEED → Subscription linked correctly
Cascade delete — deleting Market removes related MarketAnchor records
JSON field roundtrip — User.onboardingIdentity stores and returns object intact
Invalid SaturationLevel enum value throws at Prisma level
Invalid PropertyClass enum value throws
Create IntentProbe → link to Operators via IntentProbeOperator
Alert → AlertUser fan-out to multiple users

Notes on test data

Keep a test/fixtures/ directory (within web/) with factory functions (e.g. createMarket(overrides?)) that produce valid records. DB tests should call resetDb() in beforeEach to guarantee isolation. Never assert on auto-generated IDs — assert on field values. For PostGIS types, use Well-Known Text strings in test assertions. SAT_COLOR returns CSS custom property strings (var(--rust), etc.) — assert on token strings, not hex values; use SAT_COLOR_HEX when testing SVG fill= attribute output.