Files
Psychotoxical-psysonic/src/test/README.md
T
cucadmuh 7b06be5ba2 ci: make hot-path coverage gates required PR checks (#921)
* ci: make hot-path coverage gates required PR checks

Remove continue-on-error from frontend and Rust coverage jobs now that
the hot-path lists have stabilized; update docs and script headers.

* docs: note hard coverage gates in changelog and credits (PR #921)

* chore: drop credits entry for CI-only PR #921

Contributor credits are for user-visible work, not infra toggles.
2026-05-30 01:04:35 +03:00

8.2 KiB

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

npm test                       # one-shot run
npm run test:watch             # watch mode
npm run test:coverage          # with v8 coverage → ./coverage/
npm run check:css-imports      # only the global stylesheet @import graph (see below)

CSS @import graph

Vitest does not load the full global CSS bundle from main.tsx, so a broken relative @import under src/styles/** can slip past the suite until Vite runs (ENOENT from postcss-import).

After vitest run, npm test and npm run test:coverage run npm run check:css-imports, which executes scripts/check-css-import-graph.mjs and walks the same four root stylesheets as src/main.tsx, resolving only filesystem-relative imports (./…, ../…). Package imports such as @fontsource/... are ignored.

Where tests go

  • Co-located with the unit under test: Foo.tsxFoo.test.tsx, barStore.tsbarStore.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:

import {
  onInvoke, emitTauriEvent, invokeMock, tauriMockListenerCount,
  registerDefaultCoverInvokeHandlers,
} from '@/test/mocks/tauri';

beforeEach(() => {
  // Optional for cover-aware UI suites that don't assert native cache internals.
  registerDefaultCoverInvokeHandlers();
  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:

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:

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' }:

renderWithProviders(<MyComponent />, { 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(<MyComponent />) 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 a required PR check — the coverage job in frontend-tests fails when any listed file drops below the floor. 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+.