# Testing Patterns **Analysis Date:** 2026-06-09 ## Test Framework **Runner:** - Backend: Vitest 4.1.8, Node environment - Frontend: Vitest 4.1.8, jsdom environment - Config: `apps/api/vitest.config.ts`, `apps/pwa/vitest.config.ts` **Assertion Library:** - Vitest built-in `expect()` - Testing Library (`@testing-library/react`, `@testing-library/jest-dom`) for component DOM assertions - `jest-dom` matchers extended via `apps/pwa/src/test-setup.ts` **Run Commands:** ```bash # Run all tests pnpm test # Run tests in watch mode pnpm --filter @familysync/api test:watch pnpm --filter @familysync/pwa test:watch # Run with coverage (not configured yet) vitest run --coverage ``` ## Test File Organization **Location:** - Backend: `apps/api/tests/` parallel to `apps/api/src/` — mirrors source structure - Frontend: Co-located with source files — `src/components/Foo.tsx` → `src/components/Foo.test.tsx` **Naming:** - Test files: `{module}.test.ts` or `.test.tsx` - Fixtures: `apps/api/tests/fixtures/` — fixture files (e.g., `weekly-dst.ics`) loaded by test helpers **Structure:** ``` apps/api/tests/ ├── health.test.ts # End-to-end test for GET /health ├── auth/ │ ├── devBypass.test.ts │ └── user.test.ts ├── broker/ │ ├── expand.test.ts # expandOccurrences() unit tests │ ├── poller.test.ts │ ├── outboxWorker.test.ts │ ├── sync.test.ts │ ├── vevent.test.ts │ ├── write.test.ts │ └── crypto.test.ts ├── routes/ # Route handler tests TBD ├── helpers/ # Test utility functions └── fixtures/ ├── weekly-dst.ics # DST test fixture (weekly recurrence) └── allday-birthday.ics # All-day recurrence fixture apps/pwa/src/ ├── api/client.test.ts ├── lib/ │ ├── colorUtils.test.ts │ ├── eventDateTime.test.ts │ ├── hydrateEvents.test.ts │ ├── loginRedirect.test.ts │ └── calendarConfig.test.ts ├── components/ │ ├── InstallPrompt.test.tsx │ └── ... └── store/ └── (Zustand store tested via client.test.ts) ``` ## Test Structure **Suite Organization:** ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('GET /health', () => { it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => { // Arrange const { app } = await import('../src/index.js'); // Act const res = await app.request('/health'); // Assert expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; db: string }; expect(body.ok).toBe(true); }); it('returns 503 when DB round-trip throws', async () => { // Arrange const { db } = await import('../src/db/client.js'); vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')); // Act const { app } = await import('../src/index.js'); const res = await app.request('/health'); // Assert expect(res.status).toBe(503); }); }); ``` **Patterns:** - Async test functions with full await chain - Hono request testing: `app.request(path)` returns a Response object - Mock setup in `beforeEach`; cleanup in `afterEach` with `vi.unstubAllGlobals()` or `vi.clearAllMocks()` - Descriptive test names following "should [action] when [condition]" or "[verb] [noun]" pattern - Arrange-Act-Assert (AAA) comment structure for multi-step tests ## Mocking **Framework:** Vitest `vi` object (`vi.mock`, `vi.mocked`, `vi.fn`, `vi.stubGlobal`) **Module Mocking:** ```typescript // Hoist vi.mock() calls to the top of the module (Vitest requirement) vi.mock('../src/db/client.js', () => ({ db: { execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), }, })); ``` **Function Mocking:** ```typescript const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ uid: 'test-uid' }), } as Response); // Call the function under test await createEvent(payload); // Assert the mock was called correctly expect(mockFetch).toHaveBeenCalledWith( '/api/events/create', expect.objectContaining({ method: 'POST', credentials: 'include', }), ); ``` **Global Stubs (Frontend):** ```typescript beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); ``` **What to Mock:** - External I/O: database (via `vi.mock` on `src/db/client.js`) - Network calls: `fetch` (via `vi.stubGlobal('fetch', ...)`) - Environment-dependent code: `window.matchMedia` (jsdom polyfill, see test-setup.ts) - Time-dependent code: `Date`, `setTimeout` (if needed; not used currently) **What NOT to Mock:** - Pure utility functions — test them directly (colorUtils, eventDateTime, hydrateEvents) - Zod validation schemas — test with real payloads - Zustand stores — instantiate real store, call real methods - Hono app logic — use `app.request()` to test end-to-end - iCalendar parsing (ical.js) — test with real .ics fixtures, not mocks ## Fixtures and Factories **Test Data (Backend):** Fixture files are `.ics` (iCalendar) strings stored in `apps/api/tests/fixtures/`: ```typescript // Load fixture file const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8'); // Use in test const occurrences = expandOccurrences( rawVevent, new Date('2026-03-01T00:00:00Z'), new Date('2026-04-01T00:00:00Z'), 1, 'My Calendar', 1, 'Alice', '#4A90D9', false, ); ``` **Test Data (Frontend):** Inline mock objects in test files (no factory pattern needed yet): ```typescript vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ calendars: [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false, }, { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true, }, ], }), } as Response); ``` **Location:** - Fixture files: `apps/api/tests/fixtures/` — raw .ics strings for iCalendar tests - Mock payloads: inline in test files (`api/client.test.ts`, etc.) ## Coverage **Requirements:** None enforced (no coverage thresholds in vitest.config.ts) **Current State:** - Backend: Partial coverage — broker modules (expand, sync, write, crypto, vevent) tested; route handlers mostly untested - Frontend: Good coverage of utility functions (colorUtils, eventDateTime, hydrateEvents, calendarConfig) and API client **View Coverage:** ```bash # Generate coverage report (requires @vitest/coverage-v8) vitest run --coverage ``` ## Test Types **Unit Tests:** - Scope: Single function or small module in isolation (mocks external dependencies) - Approach: Test input → output contracts, edge cases, error conditions - Examples: `lib/colorUtils.test.ts`, `broker/crypto.test.ts`, `api/client.test.ts` **Integration Tests:** - Scope: Multi-module behavior (e.g., route handler + DB + auth middleware) - Approach: Test realistic user flows using `app.request()` for HTTP semantics - Examples: `health.test.ts` (GET /health with mocked DB) - No external API calls (Fastmail, Authelia mocked) **E2E Tests:** - Not implemented; would require running a real server + browser - Currently using `playwright-cli` skill for browser-based smoke tests of UI (per project CLAUDE.md) ## Common Patterns **Async Testing:** ```typescript it('returns { uid } on success', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ uid: 'returned-uid' }), } as Response); const { createEvent } = await import('./client.js'); const result = await createEvent(payload); expect(result).toEqual({ uid: 'returned-uid' }); }); ``` **Error Testing:** ```typescript it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ error: 'Bad Request' }), } as Response) const { createEvent } = await import('./client.js') await expect( createEvent({ title: '', ... }) ).rejects.toThrow() }) ``` **Status Code Testing:** ```typescript it('returns 503 when DB round-trip throws', async () => { const { db } = await import('../src/db/client.js'); vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')); const { app } = await import('../src/index.js'); const res = await app.request('/health'); expect(res.status).toBe(503); }); ``` **Fixture-Based Testing:** ```typescript describe('expandOccurrences — DST correctness', () => { it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => { const rawVevent = loadFixture('weekly-dst.ics'); const windowStart = new Date('2026-03-01T00:00:00Z'); const windowEnd = new Date('2026-04-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, 'Alice', '#4A90D9', false, ); // Check DST correctness: all occurrences must show hour === 10 local time for (const occ of occurrences) { expect(occ.start).toMatch(/T10:00:00/); expect(occ.start).toContain('[America/New_York]'); } // Explicitly check pre- and post-transition occurrences const preTransition = occurrences.find((o) => o.start.includes('2026-03-01')); const postTransition = occurrences.find((o) => o.start.includes('2026-03-15')); expect(preTransition!.start).toContain('-05:00[America/New_York]'); // EST expect(postTransition!.start).toContain('-04:00[America/New_York]'); // EDT }); }); ``` **Zustand Store Testing:** ```typescript describe('calendarStore', () => { it('setEventForm(true, edit, some-uid) updates all three keys', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid'); const state = useCalendarStore.getState(); expect(state.eventFormOpen).toBe(true); expect(state.eventFormMode).toBe('edit'); expect(state.eventFormUid).toBe('some-uid'); }); }); ``` ## Test Setup **Backend (Node environment):** - `vitest.config.ts` specifies `environment: 'node'` with `globals: true` - No test-setup file needed (Node has built-in globals) - Modules imported via `await import(...)` to enable per-test mocking **Frontend (jsdom environment):** - `vitest.config.ts` specifies `environment: 'jsdom'` with `globals: true` and `setupFiles: ['./src/test-setup.ts']` - `test-setup.ts` polyfills `window.matchMedia` (jsdom doesn't implement CSSOM MediaQueryList) - `test-setup.ts` extends `expect` with `jest-dom` matchers - Timezone pinned to UTC via `env: { TZ: 'UTC' }` for deterministic date tests (WR-05) **Example (from `apps/pwa/vitest.config.ts`):** ```typescript export default defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'], env: { TZ: 'UTC' }, }, }); ``` **Example (from `apps/pwa/src/test-setup.ts`):** ```typescript import '@testing-library/jest-dom'; Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, // ... other MediaQueryList methods }), }); ``` ## Known Testing Gaps **Backend Route Handlers:** - GET /api/events, POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid — no route tests yet (in scope for Phase 5 / Plan 05) - GET /api/events/writable-calendars, GET /api/events/sync-status — no route tests - SSE route (`/api/sse`) — not tested - Auth flow tests (dev-bypass, OIDC session) partially covered; integration tests with Authelia not applicable **Frontend Components:** - EventForm, DeleteConfirmationDialog, CalendarShell — no component tests yet - SSE event listener integration (real-time list updates) — not tested **Integration:** - Full end-to-end flow (login → fetch events → create event → poll sync-status) — not covered - Database transaction rollback on error — not explicitly tested --- _Testing analysis: 2026-06-09_