Skip to content

Patrimoine has three test layers: backend unit/integration tests (pytest), frontend unit tests (vitest), and end-to-end tests (Playwright).


A ci-local.sh script at the repository root replicates the backend, frontend and security checks from .forgejo/workflows/ci-backend.yml, ci-frontend.yml and ci-security.yml (see CI/CD Pipeline) for local execution:

Terminal window
./ci-local.sh # all checks (backend + frontend + security)
./ci-local.sh backend # backend only (lint, test, OpenAPI, migrations)
./ci-local.sh backend-cov # backend whole suite with coverage (slower)
./ci-local.sh frontend # frontend only (lint, type check, test, build, size)
./ci-local.sh security # security only (pip-audit, npm audit, gitleaks)

The backend run mirrors the CI layout: lint, three pytest shards (split by file like the CI test job) and the schema checks (OpenAPI, migrations) run in parallel, each logging to its own file, and the tail of every failed log is printed at the end. BACKEND_SHARDS (default 3) and BACKEND_WORKERS (pytest-xdist workers per shard, default 4) tune it. ./ci-local.sh backend-cov runs the whole suite with coverage, as the CI coverage job does. The other targets use set -euo pipefail and fail fast on the first error.


  • Location: backend/tests/
  • Files: 310 test_*.py files in backend/tests/
  • Test count: about 6,300 (uv run pytest tests --collect-only -q | tail -1)
  • Framework: pytest with asyncio_mode=auto
Terminal window
cd backend
uv run pytest tests/ -q

A filterwarnings entry in pyproject.toml suppresses the known aiosqlite _connection_worker_thread RuntimeError: Event loop is closed warning — a race condition where the aiosqlite background thread outlives the test event loop. This does not affect test correctness.

Terminal window
cd backend
uv run pytest tests/ -q --cov=app --cov-report=term

Configured via [tool.coverage.run]/[tool.coverage.report] in pyproject.toml (pytest-cov, branch coverage, app/ only, plus concurrency = ["greenlet", "thread"] — without the greenlet setting, coverage silently under-reports every route that runs code after an await db.execute(...)/await db.flush(), since SQLAlchemy’s async engine bridges to sync ORM code via greenlet_spawn and sys.settrace loses continuity across that switch).

The coverage job of ci-backend.yml runs the whole suite with coverage on pushes to main (non-blocking, failures are announced through ntfy); pull requests and tags run the three test shards without coverage. ./ci-local.sh backend-cov reproduces it. [tool.coverage.report] fail_under gates the run: pytest-cov reads it from pyproject.toml automatically whenever --cov is passed with no CLI override.

Coverage gate: fail_under = 92 (backend/pyproject.toml, [tool.coverage.report]). Set to the measured baseline (93% total, 2026-09-24) minus one point of headroom, which absorbs the documented flaky-rerun and timezone-boundary-window test noise without ever failing the build on that alone.

A deliberately partial run (e.g. uv run pytest tests/test_config.py -q --cov=app) fails with pytest-cov’s Required test coverage of 92.0% not reached, confirming the gate is live without any CLI flag.

Last measured (2026-09-24): 93% total coverage, up from 90% line coverage measured 2026-09-17 (different metric — fail_under reads pytest-cov’s total coverage, which blends line and the project’s branch = true setting).

Fixture Description
test_db In-memory SQLite engine. Creates all model tables via Base.metadata.create_all. Registers PRAGMA foreign_keys=ON. Overrides get_db so routes use the test engine.
client httpx.AsyncClient with ASGITransport. Sends X-Requested-With: XMLHttpRequest header (required by CSRF middleware).
test_user_a / test_user_b User instances for isolation tests. Not committed to DB.
auth_client Authenticated AsyncClient with access_token cookie set and get_current_user dependency overridden. Seeds a real user in the test DB.
test_key_pair Session-scoped RSA key pair for mock JWKS validation. Returns private_pem, public_pem, and kid.
make_test_token Factory that signs RS256 JWTs with the test private key. Accepts sub, iss, aud, exp_delta parameters.
mock_jwks_client Replaces get_signing_key in app.utils.oidc_client so OIDC tokens validate against the test public key.

Tests live in backend/tests/test_<module>.py, one file per router, service or middleware.


  • Location: frontend/src/ (co-located __tests__/ directories)
  • Test files: 372
  • Test count: about 5,000 (npx vitest list prints one entry per test)
  • Framework: vitest + jsdom
Terminal window
cd frontend
npx vitest run

Or in watch mode:

Terminal window
npx vitest
Terminal window
cd frontend
npm run test:coverage

Configured via the coverage block in vitest.config.ts (@vitest/coverage-v8). CI runs the suite in three shards without coverage; npm run test:coverage (and ./ci-local.sh frontend) measure it locally. There is no threshold gate.

Last measured (2026-09-17): 81.58% line coverage (78.58% statements, 70.83% branch, 71.08% functions). Weakest spots: App.tsx/src/ top-level routing code and error-boundary components, which are harder to exercise from component-level tests than page/hook logic.

src/test/setup.ts is loaded before every test file:

  • Mocks ResizeObserver (required by Recharts ResponsiveContainer in jsdom).
  • Mocks react-intl to use French messages directly, avoiding the need to wrap every test in IntlProvider. The mock FormattedMessage returns the French string from fr.json, and useIntl().formatMessage() does the same.
  • Mocks @/i18n to provide a useLocaleContext() stub returning { locale: 'fr', setLocale: vi.fn() }.
File Purpose
src/test/utils.tsx renderWithProviders() — wraps components in IntlProvider with French messages.
src/test/__mocks__/apiMock.ts mockApiFetch — mocks the API client for controlled responses.
File Purpose
src/__tests__/locale-parity.test.ts Verifies all five locale files (fr.json, en.json, es.json, de.json, it.json) have identical key sets, no extra/missing keys, and no empty string values.
src/__tests__/drift-guards.test.ts Repo-scan guards that fail when a replaced pattern comes back: UTC calendar dates, hand-built percentages, raw checkboxes, number inputs for money, a bare Confirm label, server text shown to the user, and an alert dialog built outside the shared confirm components.
src/__tests__/locale-plurals.test.tsx Formats every converted count message with the real ICU formatter in all five locales, with no “(s)” suffixes left.
src/__tests__/toast-text.test.ts Checks that toast messages are one clause with no trailing period in every locale, including the errors.* messages that failed mutations resolve to.

Tests use vi.mock for hooks, render components with MemoryRouter, and assert with fireEvent / screen from Testing Library.

src/__tests__/drift-guards.test.ts scans the non-test frontend source (whole files, so JSX split over lines is caught) and fails when a replaced pattern comes back. Each failure names the file:line:

  1. A calendar date cut from the UTC ISO string; use toLocalISODate
  2. A percent sign built after toFixed; use formatPercent
  3. A raw checkbox input; use Checkbox
  4. A number input; money is a text input, and ALLOWED_NUMBER_INPUTS lists the few files that take a count
  5. The common.confirm key, or a confirm button that reads as a bare Confirm in any locale; name the action
  6. serverMessage read anywhere but api/client.ts
  7. An alert-dialog import anywhere but the ui/alert-dialog.tsx wrapper and ui/ConfirmDialog.tsx (ALLOWED_ALERT_DIALOG); confirm through ConfirmDialog, as BulkActionConfirm, PermanentDeleteConfirm and EmptyTrashConfirm do
  8. A border utility on an input; use the border-input-border token
  9. A page size written as a number; use DEFAULT_PAGE_SIZE / PAGE_SIZE_OPTIONS (ALLOWED_PAGE_SIZE_LITERALS)
  10. A ChartCard or ChartTabContent with none of csvExport, textAlternative, alternativeRows or summary

Every guard has a matcher self-test (a bad and a good sample), and each allow-list fails on a stale entry, so a file comes off it when its last match goes. When a guard trips, fix the occurrence; do not widen a pattern or the allow-list. Importing toast from sonner is caught by oxlint, not by this file.

  • Mock the toast with vi.mock('@/lib/toast', ...), since components import it from there and not from sonner
  • Query a checkbox by role and name: screen.getByRole('checkbox', { name })
  • The shared setup replaces react-intl with a French pass-through that does not format ICU plurals. To assert a plural, call vi.unmock('react-intl') at the top of the file and render with renderWithRealIntl from src/test/real-intl.tsx, as src/__tests__/locale-plurals.test.tsx does

  • Location: frontend/e2e/
  • Specs: 37
  • Framework: Playwright

accessibility, account-advanced, account-close-planned, account-crud, account-display, add-snapshot, budget-charts-fx, charts-tab-link, dashboard, dashboards, duplicates-dismiss, duplicates-footer, envelopes-free-to-use, exchange-rates-footer, export-data, history, import-flow, institutions-footer, loan-payoff, login, mobile-chrome, navigation, net-worth-projection, notifications, payees-narrow-layout, planned-repeat-every, pwa, reports, rules-footer, rules-import, settings, snapshot-grid, splits-merge-ux, transactions-mobile, transactions-total-row, transfers, view-chart.

Function Description
seedAccount(request, name, type) Creates a test account via API.
seedSnapshot(request, accountId, date, balance) Creates a test snapshot via API.
cleanupAll(request) Deletes the test data (transactions, rules, saved filters and reports, planned transactions, net worth thresholds, holdings, savings goals, envelopes, tags, accounts, snapshots, tokens and exchange rates) and resets the report settings.
seedTransaction, seedPlanned, seedTag, seedSavingsGoal, seedHolding, seedEnvelope, … Create one record of each kind via the API, and throw with the status when the request is rejected.
seedLiability, seedDashboard, seedExchangeRate, seedNetWorthThreshold, getAlertRules / putAlertRules / evaluateAlerts / setNotificationChannel, markTourSeen, setUserLocale, ensureCategory, … Seed or configure the matching record through the API.
setUserTheme(request, theme, mode) Sets the user’s skin and mode on the server, which the app applies over localStorage.
E2E_BYPASS_TOKEN Auth bypass cookie value (from E2E_BYPASS_TOKEN env or default).
  • Browser: Chromium only.
  • Parallelism: Serial (fullyParallel: false).
  • webServer: Runs podman compose with the e2e override (docker-compose.e2e.yml), which sets E2E_TEST_MODE=true.
  • Timeout: 60s per test, 180s for server startup.
Terminal window
cd frontend
npx playwright test

To run against a stack that is already up (no podman compose), set E2E_BASE_URL, for example E2E_BASE_URL=http://127.0.0.1:8081 npx playwright test. The webServer is then skipped. Use 127.0.0.1 or localhost rather than a LAN IP: browsers only allow service workers on a secure origin, so the PWA specs fail on a LAN address.

accessibility.spec.ts runs axe on every signed-in route of App.tsx plus /login, in three configurations: light and dark at 1280 px, and light at 390 px. It uses the WCAG 2.0 and 2.1 A and AA rules (wcag2a, wcag2aa, wcag21a, wcag21aa) and fails on any serious or critical violation. Before the scans it seeds real content: accounts in EUR and USD with no USD rate (so the missing-rates notice shows), snapshots on three dates, categorised and tagged transactions, a planned transaction, a savings goal, a holding, an envelope and a net worth threshold.

  • Dependency: @axe-core/playwright is a frontend devDependency pinned exactly to 4.13.0. Only e2e/ imports it, and the app is built from src/, so axe never reaches the app bundle.
  • Route list: a test compares the spec’s page list with the routes declared in App.tsx, so a new route cannot be left out silently.
  • Known issues: a violation that cannot be fixed yet goes into KNOWN_ISSUES in the spec, with its route, rule, selector, reason and follow-up. An unlisted violation fails the scan, and so does a listed entry that no longer reproduces, so the list only shrinks.
  • Field borders: axe has no rule for non-text contrast, so the 3:1 contrast of input borders is checked by the unit test src/lib/__tests__/skin-contrast.test.ts.

Run it only with E2E_BASE_URL set to a stack that has E2E_TEST_MODE on, never against a live deployment: the spec starts with cleanupAll, which deletes the data.

Terminal window
cd frontend
E2E_BASE_URL=http://127.0.0.1:8081 npx playwright test e2e/accessibility.spec.ts