The web app has about 175 Vitest files in apps/saas: roughly 130 .test.ts and 45 .test.tsx. Tests sit next to the code they cover. There is one Playwright spec.

Commands

Run a focused file while working. Run the whole suite before pushing.

Vitest configuration

apps/saas/vitest.config.ts:
  • The default environment is node. There is no global DOM.
  • jsx: "automatic" matches how Next compiles JSX. Without it component files throw “React is not defined” under Vitest.
  • The aliases repeat the paths from tsconfig.json. When you add a path alias, add it in both files.
  • tests/ is excluded. That folder belongs to Playwright.
  • There is no setup file and no @testing-library. Tests use React’s own APIs.

Three kinds of test

1. Pure model tests

Most tests. Feature logic is extracted into plain modules (*-model.ts, *-logic.ts, *-utils.ts and the shared lib files) so it can be tested without rendering. Examples: clients/board/trainee-board.test.ts (598 lines covering the filter model), templates/builder/auto-fill.test.ts, modules/payments/lib/plan-picker.test.ts, modules/shared/lib/core-api.test.ts, modules/shared/lib/media-upload.test.ts. Prefer this kind. If a component is hard to test, move the decision into a function and test the function.

2. Static render tests

About 25 files render a component to a string with renderToStaticMarkup from react-dom/server and assert on the markup. They run in the node environment and need no DOM. Good for “does this state show that label”. Components that call useTranslations are wrapped in NextIntlClientProvider with a messages object (36 test files do this). Some build that object from the colocated _*-i18n-keys.json fragment files.

3. DOM tests with happy-dom

Used when the test must click, type or inspect what Radix rendered. happy-dom is a dev dependency of saas. Two styles exist. Per file environment. 17 files start with a pragma:
Vitest then provides window and document for that file. Manual window. Other files stay in the node environment and build the DOM themselves:
That block is from modules/shared/components/dialog-layout.test.tsx. templates/FolderColorPicker.test.tsx uses the same list. Details that matter:
  • Lower case globals such as getComputedStyle and requestAnimationFrame are functions that need this to be the window, so they are bound. Constructors (capitalised) are passed as they are.
  • IS_REACT_ACT_ENVIRONMENT silences React’s warning about act.
  • Rendering and unmounting go through act.

Radix and import order

In the manual style, no DOM global exists while the test file’s top level imports are evaluated. Radix based components touch document, ResizeObserver and friends, so the component under test is imported inside the test, after the stubs are in place:
A static import at the top of such a file is evaluated before beforeEach runs. Stub first, then await import(...). Portalled content (dialogs, popovers, dropdowns) is appended to document.body, not to your container. Query from document. happy-dom does not lay anything out. Do not assert on measured sizes. Assert on classes, attributes and structure, as the dialog test does.

Mocking

About 45 files use vi.mock. The most common targets, by count:
  • A sibling module, usually the feature’s actions.ts, so the test controls what the “server” returns.
  • server-only (17 files), so a server module can be imported in a test at all.
  • @shared/lib/perform-api (12 files) and next/cache (11 files), to test server actions without a network or a Next runtime.
  • next/font/google, next/headers, next-intl and the toast module, for components and pages that import them.
use-async-action.test.ts tests the queue through createAsyncActionQueue(), the React free core of the hook. Follow that pattern: export the core, test the core.

Structural guard tests

A few tests check the codebase itself.

app/server-client-boundary.test.ts

Walks every page.tsx, layout.tsx, template.tsx, default.tsx and not-found.tsx under app/. For each one that is not a client module, it resolves its named imports (relative and @shared/) and fails when a non component value is imported from a file that starts with "use client". The reason: a server component that calls a function or reads a constant exported from a client module receives a client reference, not the value. Calling it throws “Attempted to call X() from the server but X is on the client”. That took the automations page down once. Components (PascalCase names) may cross the boundary. Functions and constants may not. Put shared helpers and constants in a plain module with no "use client". There is one known exception in the test’s KNOWN set: NAV_COLLAPSED_COOKIE, exported from the client module AppWrapper.tsx and read by server layouts. The test’s comment says to remove the entry once the constant moves to a plain module.

modules/shared/components/dialog-layout.test.tsx

Asserts that DialogContent and AlertDialogContent keep grid-cols-[minmax(0,1fr)], so a long unbroken string cannot widen a dialog. See The UI package.

proxy.test.ts

Checks that the proxy rejects a malformed next-action header before it reaches Next.js.

modules/shared/lib/core-api.test.ts and modules/admin/lib/admin-api.test.ts

core-api.test.ts covers retry on a connection timeout, 204 and empty bodies, the CoreApiError code on failure, and that a raw body is sent verbatim and feeds the signature. admin-api.test.ts covers how the admin POST helper reports a refusal’s reason and details.

Playwright

apps/saas/playwright.config.ts: It loads ../../.env.local with dotenv.
The only spec, tests/login.spec.ts, is out of date. It expects an English “Welcome back” heading, a “Magic link” tab and a “Login with passkey” button. In the current config the default locale is Hebrew, magic link and passkeys are disabled, and the login tabs are phone OTP, email OTP and password. The spec will fail against the app as it is. Playwright is not part of ci.yml.
A real e2e run also needs the core API running, because every page talks to it.

What CI runs

.github/workflows/ci.yml runs pnpm test (Vitest) after type-check and lint. See Tooling and scripts.

Writing a new test

1

Put the logic in a pure module

A *-model.ts next to the component. Test it with plain inputs and outputs.
2

Name it after the file

foo.ts and foo.test.ts in the same folder.
3

Pick the lightest environment

Node for logic, renderToStaticMarkup for output, happy-dom only for interaction.
4

For Radix, import late

Stub the DOM, then await import() the component inside the test.
5

Add regression tests for bugs

Several tests in the repo exist because of a specific incident. Their comments say which.