| Layer | Tool | Location |
|---|---|---|
| Unit / Component | Vitest + Testing Library | Colocated *.test.ts / *.test.tsx |
| DB Integration | Vitest + real Supabase | packages/db/src/**/*.test.ts |
| E2E | Playwright | packages/e2e-web/src/features/ |
Shared presets live in packages/vitest-config:
@repo/vitest-config/base)For packages that don't need a DOM:
// packages/vitest-config/src/base.ts
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
},
});
@repo/vitest-config/react)For packages and apps that test React components:
// packages/vitest-config/src/react.ts
export default mergeConfig(baseConfig, defineConfig({
plugins: [react({})],
test: {
environment: "jsdom",
},
}));
The React setup file (@repo/vitest-config/setup-files/react.ts) registers:
@testing-library/jest-dom matchersmatchMedia mockResizeObserver mockPackages extend the shared presets in their own vitest.config.ts:
// apps/web/vitest.config.ts
import { mergeConfig } from "vitest/config";
import react from "@repo/vitest-config/react";
export default mergeConfig(react, {
test: {
include: ["**/*.test.{ts,tsx}"],
setupFiles: ["@repo/vitest-config/setup-files/react"],
alias: {
"~": new URL("./", import.meta.url).pathname,
"~tests": new URL("./__tests__", import.meta.url).pathname,
},
},
});
*.test.ts for logic, *.test.tsx for components.describe, it, expect, vi are globally available (no imports needed).vi.mock() for module mocks.The web app provides a custom render function that wraps components in the ThemeProvider:
// apps/web/__tests__/index.tsx
import { render as rtlRender } from "@testing-library/react";
import { ThemeProvider } from "@repo/ui/ThemeProvider";
function render(ui: React.ReactElement, options = {}) {
return rtlRender(ui, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
...options,
});
}
export * from "@testing-library/react";
export { render };
Usage in tests:
import { render, screen } from "~tests";
describe("MyComponent", () => {
it("renders correctly", () => {
render(<MyComponent />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
});
Some packages export test utilities via a ./tests entry point so consumers can reuse mocks and helpers:
import { mockTRPCContext } from "@repo/trpc/tests";
import { mockThemeProvider } from "@repo/ui/tests";
When multiple tests need the same data shape, create a factory — a function that returns a valid object with sensible defaults and accepts partial overrides.
| Location | When to use | Import style |
|---|---|---|
apps/web/__tests__/factories/ |
Entity used by 2+ modules, or a core domain entity (patient, user, org) | import { makeFoo } from "~tests/factories" |
<module>/__tests__/ |
Entity only relevant within that module | import { makeFoo } from "../__tests__/makeFoo" |
Start module-specific; promote to shared when a second module needs the same factory.
make<EntityName>.tsmake<Entity>(overrides?: Partial<T>): T — callers only specify the fields they care about.MakeAuditRunByIdDataOverrides for an example).reset*Seq() export when tests need deterministic IDs.@repo/db or @repo/trpc — no any.// apps/web/__tests__/factories/makePatient.ts
import type { PatientId } from "@repo/db/schema";
let seq = 1;
export function resetPatientIdSeq() { seq = 1; }
export function makePatient(overrides: Partial<Patient> = {}): Patient {
const id = overrides.id ?? (`${seq++}` as PatientId);
return {
id,
name: "Jane Doe",
date_of_birth: "1990-01-01",
...overrides,
};
}
For a real-world module-specific example, see apps/web/shared/audit-runs/__tests__/makeAuditRunByIdData.ts.
DB tests in packages/db run against a real local Supabase instance.
packages/db/__tests__/setup.ts handles:
dotenv/config for DATABASE_URL.afterEach).// packages/db/vitest.config.ts
export default mergeConfig(base, {
test: {
fileParallelism: false, // Tests run sequentially (shared DB)
setupFiles: ["./__tests__/setup.ts"],
},
});
# Start local Supabase (required)
pnpm --filter @repo/db test:supabase-start
# Run DB tests
pnpm --filter @repo/db test
End-to-end tests live in packages/e2e-web and use Playwright with Chromium.
packages/e2e-web/
├── src/
│ ├── features/ # Test specs organized by feature
│ │ ├── auth/ # Login, signup, password flows
│ │ ├── clients/ # Client management
│ │ ├── templates/ # Template CRUD
│ │ └── settings/ # Settings page
│ ├── fixtures/ # Reusable test fixtures
│ │ ├── controllers/ # Page object models
│ │ ├── users/ # Test user factories
│ │ └── data/ # Test data
│ ├── lib/ # Utilities (DB, Supabase client, env)
│ ├── global-setup.ts # Supabase health check, user creation
│ └── global-teardown.ts # Cleanup
├── playwright.config.ts
└── .env.test
E2E tests use a fixture pattern with controllers (page objects) and test users:
// Spec file
test("can create a client", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/clients");
// ...
});
# Start E2E Supabase instance
pnpm --filter @repo/e2e-web test:supabase-start
# Run E2E tests (starts web server automatically)
START_WEB_SERVER=true pnpm --filter @repo/e2e-web test:e2e
In CI, test results are reported to Currents for dashboard visibility. The config is in packages/e2e-web/currents.config.ts.
| Command | What It Runs |
|---|---|
pnpm test |
All unit tests across the monorepo (via Turbo) |
pnpm test:watch |
All unit tests in watch mode |
pnpm --filter @repo/db test |
DB integration tests only |
pnpm --filter @repo/e2e-web test:e2e |
Playwright E2E tests |
pnpm --filter web test |
Web app unit tests only |
pnpm --filter @repo/ui test |
UI package tests only |
Turbo's test task depends on ^build, so dependencies are built before tests run. Test outputs are cached based on test file inputs and env vars.
Next: CI/CD