Testing
Patrimoine has three test layers: backend unit/integration tests (pytest), frontend unit tests (vitest), and end-to-end tests (Playwright).
Running CI Checks Locally
Section titled “Running CI Checks Locally”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:
./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.
Backend Tests
Section titled “Backend Tests”- Location:
backend/tests/ - Files: 310
test_*.pyfiles inbackend/tests/ - Test count: about 6,300 (
uv run pytest tests --collect-only -q | tail -1) - Framework: pytest with
asyncio_mode=auto
Running
Section titled “Running”cd backenduv run pytest tests/ -qA 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.
Coverage
Section titled “Coverage”cd backenduv run pytest tests/ -q --cov=app --cov-report=termConfigured 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).
Fixtures (conftest.py)
Section titled “Fixtures (conftest.py)”| 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. |
Test Files
Section titled “Test Files”Tests live in backend/tests/test_<module>.py, one file per router, service or middleware.
Frontend Tests
Section titled “Frontend Tests”- Location:
frontend/src/(co-located__tests__/directories) - Test files: 372
- Test count: about 5,000 (
npx vitest listprints one entry per test) - Framework: vitest + jsdom
Running
Section titled “Running”cd frontendnpx vitest runOr in watch mode:
npx vitestCoverage
Section titled “Coverage”cd frontendnpm run test:coverageConfigured 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 RechartsResponsiveContainerin jsdom). - Mocks
react-intlto use French messages directly, avoiding the need to wrap every test inIntlProvider. The mockFormattedMessagereturns the French string fromfr.json, anduseIntl().formatMessage()does the same. - Mocks
@/i18nto provide auseLocaleContext()stub returning{ locale: 'fr', setLocale: vi.fn() }.
Helpers
Section titled “Helpers”| 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. |
Key Test Files
Section titled “Key Test Files”| 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. |
Test Pattern
Section titled “Test Pattern”Tests use vi.mock for hooks, render components with MemoryRouter, and
assert with fireEvent / screen from Testing Library.
Drift Guards
Section titled “Drift Guards”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:
- A calendar date cut from the UTC ISO string; use
toLocalISODate - A percent sign built after
toFixed; useformatPercent - A raw checkbox input; use
Checkbox - A number input; money is a text input, and
ALLOWED_NUMBER_INPUTSlists the few files that take a count - The
common.confirmkey, or a confirm button that reads as a bare Confirm in any locale; name the action serverMessageread anywhere butapi/client.ts- An
alert-dialogimport anywhere but theui/alert-dialog.tsxwrapper andui/ConfirmDialog.tsx(ALLOWED_ALERT_DIALOG); confirm throughConfirmDialog, asBulkActionConfirm,PermanentDeleteConfirmandEmptyTrashConfirmdo - A border utility on an input; use the
border-input-bordertoken - A page size written as a number; use
DEFAULT_PAGE_SIZE/PAGE_SIZE_OPTIONS(ALLOWED_PAGE_SIZE_LITERALS) - A
ChartCardorChartTabContentwith none ofcsvExport,textAlternative,alternativeRowsorsummary
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.
Frontend Test Recipes
Section titled “Frontend Test Recipes”- Mock the toast with
vi.mock('@/lib/toast', ...), since components import it from there and not fromsonner - Query a checkbox by role and name:
screen.getByRole('checkbox', { name }) - The shared setup replaces
react-intlwith a French pass-through that does not format ICU plurals. To assert a plural, callvi.unmock('react-intl')at the top of the file and render withrenderWithRealIntlfromsrc/test/real-intl.tsx, assrc/__tests__/locale-plurals.test.tsxdoes
E2E Tests
Section titled “E2E Tests”- 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.
Helpers (helpers.ts)
Section titled “Helpers (helpers.ts)”| 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). |
Configuration
Section titled “Configuration”- Browser: Chromium only.
- Parallelism: Serial (
fullyParallel: false). - webServer: Runs
podman composewith the e2e override (docker-compose.e2e.yml), which setsE2E_TEST_MODE=true. - Timeout: 60s per test, 180s for server startup.
Running
Section titled “Running”cd frontendnpx playwright testTo 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 scan
Section titled “Accessibility scan”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/playwrightis a frontend devDependency pinned exactly to4.13.0. Onlye2e/imports it, and the app is built fromsrc/, 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_ISSUESin 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.
cd frontendE2E_BASE_URL=http://127.0.0.1:8081 npx playwright test e2e/accessibility.spec.ts