# Frontend test framework Vitest + jsdom + @testing-library/react. Existing util tests in `src/utils/*.test.ts` keep working; this folder hosts the harness for store, hook, component and (eventually) integration tests. ## Layout ``` src/test/ setup.ts # global: jest-dom, cleanup, vi.mock for tauri/*, # localStorage polyfill, browser-mock install mocks/ tauri.ts # programmable invoke() + listen() helpers, # tauriMockListenerCount(event) for lifecycle tests subsonic.ts # realistic Subsonic fixture data browser.ts # ResizeObserver / IntersectionObserver / # matchMedia / clipboard / object URL mocks helpers/ factories.ts # makeTrack / makeTracks / makeSubsonicSong / # makeServer / makeAuthState / makeQueueState storeReset.ts # resetPlayerStore / resetAuthStore / # resetPreviewStore / resetOrbitStore / # resetAllStores renderWithProviders.tsx # render() wrapped with MemoryRouter + i18n # (en-pinned by default) README.md # this file ``` ## Running tests ```bash npm test # one-shot run npm run test:watch # watch mode npm run test:coverage # with v8 coverage → ./coverage/ ``` ## Where tests go - **Co-located with the unit under test**: `Foo.tsx` → `Foo.test.tsx`, `barStore.ts` → `barStore.test.ts`. Mirrors the existing util test layout and avoids a parallel directory tree. - Vitest picks them up via `include: src/**/*.test.{ts,tsx}` in `vitest.config.ts`. ## Mocking Tauri `@tauri-apps/api/core` and `@tauri-apps/api/event` are mocked globally in `setup.ts`. Configure per-test behaviour via the helpers in `mocks/tauri.ts`: ```ts import { onInvoke, emitTauriEvent, invokeMock, tauriMockListenerCount } from '@/test/mocks/tauri'; beforeEach(() => { onInvoke('audio_play', () => undefined); }); it('responds to engine events', () => { emitTauriEvent('audio:progress', { id: 't1', currentTime: 42 }); expect(invokeMock).toHaveBeenCalledWith('audio_play', { id: 't1' }); }); it('does not double-register listeners on re-init', () => { // ... call init logic twice, assert listener count is 1 expect(tauriMockListenerCount('audio:progress')).toBe(1); }); ``` Unhandled `invoke()` calls throw a descriptive error — tests are honest about which commands they exercise. Handlers + listeners are auto-cleared between tests. ## Mocking Subsonic / HTTP Hoist the mock in the test file (vitest limitation — factory functions can't import helper modules at hoist time), then inject realistic fixture data from `mocks/subsonic.ts`: ```ts import { vi, describe, it, beforeEach, expect } from 'vitest'; vi.mock('@/api/subsonic'); import { getAlbum, buildStreamUrl } from '@/api/subsonic'; import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic'; beforeEach(() => { vi.mocked(getAlbum).mockResolvedValue(sampleAlbumWithSongs); vi.mocked(buildStreamUrl).mockImplementation(mockStreamUrl); }); ``` For broader integration tests that touch many endpoints we may introduce **MSW** later. The framework is intentionally MSW-free right now to keep the dep surface small until we need it. ## Resetting stores Zustand stores are module-level singletons and leak state across tests unless explicitly reset. setup.ts already clears localStorage between tests, but the in-memory `getState()` snapshot survives. Use `helpers/storeReset.ts`: ```ts import { resetPlayerStore, resetAllStores } from '@/test/helpers/storeReset'; describe('myFeature', () => { beforeEach(resetPlayerStore); // or, for cross-store tests: beforeEach(resetAllStores); }); ``` Each reset replaces the live state with the snapshot captured at module import time. Action references are preserved (they're closed over the original `set`/`get`, stable across `setState`). ## i18n language is pinned to `en` `renderWithProviders` calls `i18n.changeLanguage('en')` synchronously before render, so `getByText('Settings')` finds the English label regardless of the developer's local language preference. Tests that want to assert against another translation pass `{ language: 'de' }`: ```ts renderWithProviders(, { language: 'de' }); ``` Rationale: option 5a from the pre-refactor testing plan (2026-05-11). Without a fixed test language, every translation edit risks flipping a green test red on a contributor's machine. ## Patterns ### Pure utilities Direct import + assert (see `src/utils/dynamicColors.test.ts`). No setup needed beyond `import { describe, it, expect } from 'vitest'`. ### Zustand stores - Import the hook, drive it via `useFooStore.getState()`. - Reset state in a `beforeEach` via `storeReset.ts`. - Stub Tauri side effects via `onInvoke()`. - Use `emitTauriEvent()` to drive event-driven state transitions. See `src/store/previewStore.test.ts` for the reference pattern. ### Components - `renderWithProviders()` from `helpers/renderWithProviders`. - Prefer `getByRole({ name: ... })` over `getByText` when a semantic role exists — the role survives translation tweaks and refactors that move labels into different elements. - Fall back to `data-testid` only when the DOM provides no semantic anchor. - Use `userEvent` (not `fireEvent`) for click / type / keyboard, with the exception of `keydown` on `window` for global shortcut paths. See `src/components/CoverLightbox.test.tsx`. ### Hooks Wrap in `renderHook()` from `@testing-library/react`. Provide custom wrappers when the hook reads from a provider. ## What to NOT mock - **Real Zustand stores.** The whole point of characterization tests is to exercise the actual state graph. Mock only at the system boundary (Tauri / network / browser APIs). - **The router.** `MemoryRouter` via `renderWithProviders` is fine — don't stub `useNavigate` etc. unless a test specifically inspects navigation. - **react-i18next.** `I18nextProvider` with the real `i18n.ts` instance is cheap and avoids tests that lie about labels. ## What to NOT snapshot - Large rendered trees from `renderWithProviders`. Translation edits, CSS class renames, or unrelated child-component refactors flip the snapshot for reasons unrelated to the unit under test. Assert on specific observable behaviour instead — a button is enabled, an aria-label is present, a class is set. - Zustand state snapshots that include action functions. Function identity changes break the snapshot without any behavioural reason. ## Coverage gates - `vitest run --coverage` writes `coverage/coverage-summary.json` which the hot-path gate consumes. - `.github/frontend-hot-path-files.txt` lists the files held to ≥70% line coverage by `scripts/check-frontend-hot-path-coverage.sh`. - CI runs both. The gate is soft today (`continue-on-error: true`) — it flips to a hard PR-blocker at the start of M4 in the pre-refactor testing plan. Mirrors the backend rust-tests rollout. ## Process isolation `vitest.config.ts` pins `pool: 'forks'` + `isolate: true`. Each test file runs in its own forked process with a fresh module graph. ~20% slower locally than the default thread pool but avoids the fake-timer + module-mock + Zustand-global flake class that surfaces around suite-size 30+.