writing-unit-tests

star 36.5k

Use when writing, extending, or debugging Vitest unit tests anywhere in the Trilium monorepo — Preact components, jQuery widgets, client services, or the server/trilium-core backend. Covers how to render components (zero new deps), the easy-froca/becca fixtures, supertest API patterns, the honest coverage config, running a single test, and the known gotchas.

TriliumNext By TriliumNext schedule Updated 6/5/2026

name: writing-unit-tests description: Use when writing, extending, or debugging Vitest unit tests anywhere in the Trilium monorepo — Preact components, jQuery widgets, client services, or the server/trilium-core backend. Covers how to render components (zero new deps), the easy-froca/becca fixtures, supertest API patterns, the honest coverage config, running a single test, and the known gotchas.

Writing unit tests in Trilium

Trilium is a pnpm monorepo tested with Vitest (v8 coverage). This skill captures the patterns that actually work here, plus the footguns that waste time. Read the per-layer reference file for the area you're touching.

First principle: prefer extracting pure logic

The dominant, lowest-risk pattern across this repo is extract the decision/transform logic out of a component/widget/route into a top-level export function that takes plain inputs and returns a plain value, then test that function. Rendering and side effects stay thin; the logic gets covered cheaply. apps/client/src/widgets/ribbon/FormattingToolbar.tsx (getFormattingToolbarState, tested in FormattingToolbar.spec.ts) is the canonical example. Reach for rendering/integration only when the behavior is the DOM/HTTP.

Also follow CLAUDE.md: write concise tests (group related assertions in one it, don't make one test per trivial passthrough), and when you add pure business logic, extract + unit-test it.

Which technique? (decision tree)

You're testing… Technique Reference
A reusable Preact component (apps/client/src/widgets/react/) Render with raw preact render() into a happy-dom div client-components.md
A jQuery widget / type widget Extract logic → test fn; or instantiate + assert on $widget client-logic-and-services.md
A client service (apps/client/src/services/) easy-froca + override server.*; or pure logic client-logic-and-services.md
A server service (apps/server/src or packages/trilium-core/src) Real in-memory DB (sql_init + cls.init) or mocked becca server-and-core.md
A shared core API route (packages/trilium-core/src/routes/api/*) CoreApiTester — in-process, cross-runtime, real services (incl. zip export/import/multipart), minimal mocks server-and-core.md Pattern 0
An internal REST API route's Express transport (CSRF/auth/wiring) supertest agent + /login + /bootstrap CSRF server-and-core.md Pattern 1
An ETAPI endpoint supertest + basic-auth via spec/etapi/utils.ts server-and-core.md
Pure logic (parsers, formatters, math, data maps) Plain Vitest, no harness any reference

Running tests

  • Whole package: pnpm --filter <pkg> test (e.g. @triliumnext/client, @triliumnext/server, @triliumnext/commons).
  • Single file (server): pnpm --filter server test spec/etapi/search.spec.ts
  • Single file (client): pnpm --filter @triliumnext/client exec vitest run src/widgets/react/Button.spec.tsx
  • Coverage: append --coverage.
  • Server tests run sequentially (shared DB, pool: "forks", fork isolation is per file). Client/package tests run in parallel.

Windows/sandbox note: pnpm --filter … exec vitest can trigger a pnpm auto-install that hits EPERM. If so, run the hoisted binary directly (it lives in the repo-root node_modules): CI=true node node_modules/vitest/vitest.mjs run <spec> --root apps/client, or node_modules/.bin/vitest.CMD run <spec> --root apps/<app>.

Coverage config rules (Vitest 4)

Each project's test config (vite.config.* / vitest.config.*) measures coverage honestly via:

coverage: {
    provider: "v8" as const,
    include: ["src/**/*.{ts,tsx}"],            // makes UNTESTED files count too
    exclude: ["**/*.{test,spec}.{ts,mts,cts,tsx,js,jsx}", "**/*.d.ts"],
    reporter: ["text", "lcov"]
}
  • Do NOT use all: true — it was removed in Vitest 4 and is a type error; include already pulls in untested files.
  • If a config sets Vite root: "src" (e.g. apps/standalone), coverage include globs resolve relative to src, so use ["**/*.{ts,tsx}"], not ["src/**/…"].
  • Files outside the project root need coverage.allowExternal: true. v8 defaults it to false, which silently drops every out-of-root file — so an include glob alone (e.g. ../../packages/trilium-core/src/**) is ignored and contributes nothing. trilium-core has no runner of its own; its coverage is measured through apps/server and apps/standalone, and both must set allowExternal: true plus a core glob in coverage.include whose ../ depth matches that suite's root: ../../packages/trilium-core/src/** for server (root apps/server), ../../../packages/trilium-core/src/** for standalone (root apps/standalone/src). Without allowExternal core never reaches the lcov or Codecov. The lcov writes these as ../…/packages/… paths; codecov.yml's fixes: entries strip the ../ so they map onto the repo tree.
  • For provably-unreachable defensive branches, mark them with /* v8 ignore next */ / /* v8 ignore start */…/* v8 ignore stop */ and a one-line reason — don't delete the guard or write a fake test.
  • Checking one file's coverage: the v8 text reporter crashes (PARSE_ERROR while remapping unrelated uncovered core files) on single-spec --coverage runs. Produce lcov/json/json-summary instead and parse it with the analyzing-coverage skill's coverage.mjs (… summary for pct/aggregate, … gaps --filter <file> for the uncovered line list). The full-suite text report (run over a directory) is fine. Don't hand-roll a coverage parser — that script already handles all three formats and the Windows footguns.

Universal gotchas

  • No non-null assertions (!) — never use the TypeScript postfix ! operator, even in tests. Narrow instead: becca.getNoteOrThrow(id)/getAttachmentOrThrow(id) instead of becca.getNote(id)!; value?.prop ?? fallback then assert; or capture into a const after an expect(x).toBeDefined()/null check. (Project rule — see CLAUDE.md Code Style.)
  • vi.mock is hoisted above imports. Put component/module imports after the vi.mock(...) calls; mock factories can't reference outer non-hoisted variables. Partial-mock with async (importOriginal) => ({ ...(await importOriginal()), onlyThis: vi.fn() }).
  • Don't assert on translated (i18n) strings — assert structure/keys/behavior (classes, counts, ids), not human-readable English.
  • happy-dom is not a browser: getBoundingClientRect() returns zeros, ResizeObserver/layout/visibility are stubs. Anything pixel/size/scroll-based needs @vitest/browser, not happy-dom.
  • Reserve @vitest/browser (already a dependency, currently unconfigured) for real-layout/integration needs (CKEditor, Excalidraw, Modal transitions, size measurement) — not for normal unit tests.
Install via CLI
npx skills add https://github.com/TriliumNext/Trilium --skill writing-unit-tests
Repository Details
star Stars 36,470
call_split Forks 2,443
navigation Branch main
article Path SKILL.md
More from Creator