12 KiB
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-dommatchers extended viaapps/pwa/src/test-setup.ts
Run Commands:
# 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 toapps/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.tsor.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:
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 inafterEachwithvi.unstubAllGlobals()orvi.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:
// 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:
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):
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
What to Mock:
- External I/O: database (via
vi.mockonsrc/db/client.js) - Network calls:
fetch(viavi.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/:
// 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):
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:
# 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-cliskill for browser-based smoke tests of UI (per project CLAUDE.md)
Common Patterns
Async Testing:
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:
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:
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:
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:
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.tsspecifiesenvironment: 'node'withglobals: 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.tsspecifiesenvironment: 'jsdom'withglobals: trueandsetupFiles: ['./src/test-setup.ts']test-setup.tspolyfillswindow.matchMedia(jsdom doesn't implement CSSOM MediaQueryList)test-setup.tsextendsexpectwithjest-dommatchers- Timezone pinned to UTC via
env: { TZ: 'UTC' }for deterministic date tests (WR-05)
Example (from apps/pwa/vitest.config.ts):
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: { TZ: 'UTC' },
},
})
Example (from apps/pwa/src/test-setup.ts):
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