Testing
Vitest for unit tests, Playwright for E2E.
Unit tests (Vitest)
Configuration
File: vitest.config.ts
{
test: {
environment: "jsdom",
globals: true, // describe, it, expect, vi without imports
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
include: ["src/lib/**", "src/modules/**"],
},
},
resolve: {
alias: {
"~": "./src",
"generated": "./generated",
},
},
}Commands
pnpm test # Run all tests once
pnpm test:watch # Watch mode (re-runs on change)
pnpm test:security # Security tests (guards, permissions)
pnpm test:security:full # Full audit (IDOR, crypto, CVE, middleware)Target a specific file
npx vitest run src/server/api/routers/__tests__/facturation-calculs.test.tsConventions
| Convention | Detail |
|---|---|
| Location | Colocated: src/lib/foo/__tests__/foo.test.ts or src/lib/foo/foo.test.ts |
| Routers | src/server/api/routers/__tests__/ |
| Security | src/__tests__/security/ |
| DB | Mock Prisma — never a real DB in unit tests |
| Globals | describe, it, expect, vi available without imports |
Setup
File: src/test/setup.ts
Polyfills for the jsdom environment:
window.matchMedia(Radix UI)ResizeObserver- Testing Library (
@testing-library/jest-dom)
Writing a new unit test
// src/lib/my-module/__tests__/my-module.test.ts
import { myFunction } from "../my-module";
describe("myFunction", () => {
it("should return the expected result", () => {
const result = myFunction({ input: "test" });
expect(result).toBe("expected");
});
it("should throw if the input is invalid", () => {
expect(() => myFunction({ input: "" })).toThrow();
});
});Mocking Prisma
import { vi } from "vitest";
// Mock the Prisma client
vi.mock("~/server/db", () => ({
db: {
invoice: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "test-id" }),
update: vi.fn().mockResolvedValue({}),
},
},
}));Mocking the tRPC context
const mockCtx = {
session: {
user: {
id: "user-1",
role: "ADMIN",
organizationId: "org-1",
},
},
orgDb: {
invoice: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(mockInvoice),
},
},
db: {
invoice: {
update: vi.fn().mockResolvedValue({}),
},
},
};E2E tests (Playwright)
Configuration
File: playwright.config.ts
{
testDir: "e2e",
timeout: 60000,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "setup", testMatch: "**/global-setup.ts" },
{ name: "public", testMatch: "**/public/**" },
{
name: "authenticated",
testMatch: "**/authenticated/**",
dependencies: ["setup"],
use: { storageState: "e2e/.auth/user.json" },
},
],
}Commands
pnpm test:e2e # Run all E2E tests
pnpm test:e2e:ui # UI mode (graphical interface)
pnpm test:e2e:headed # Headed mode (visible browser)Projects
| Project | Description | Auth |
|---|---|---|
setup | Global login, saves the auth state | — |
public | Public pages (smoke, auth, portal) | No |
authenticated | Dashboard, invoicing, banking | Yes (via setup) |
Auth state
The setup project signs in once and saves the authentication state in e2e/.auth/user.json (gitignored). The authenticated tests reuse this state.
Writing an E2E test
// e2e/authenticated/facturation.spec.ts
import { test, expect } from "@playwright/test";
test("should display the invoice list", async ({ page }) => {
await page.goto("/dashboard/facturation");
await expect(page.getByRole("heading", { name: "Invoices" })).toBeVisible();
});
test("should create an invoice", async ({ page }) => {
await page.goto("/dashboard/facturation");
await page.getByRole("button", { name: "New invoice" }).click();
// ... fill in the form
await expect(page.getByText("Invoice created")).toBeVisible();
});Public page test (no auth)
// e2e/public/landing.spec.ts
import { test, expect } from "@playwright/test";
test("the landing page loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/HeartCo/);
});Security tests
Permission guards
pnpm test:securityVerifies that the tRPC procedures use the right guards:
staffProcedureat minimum for internal routesrequirePermission()for sensitive actionsadminProcedurefor critical settings
Full audit
pnpm test:security:fullExtended tests:
- IDOR: cross-organization access attempt
- Crypto: HMAC verification, token encryption
- CVE: known protections (middleware bypass, etc.)
- Middleware: CSP, headers, rate limiting
Fixing conventions
| Rule | Detail |
|---|---|
| Fix the code | NEVER edit the tests to make them pass |
| Max attempts | 5 per test before marking it "blocked" |
| Logging | Log the fixes in test-reports/corrections.log |
| Pre-existing failures | Note them, don't fix (unless related to the task) |
Existing tests
21 test files in src/server/api/routers/__tests__/:
facturation-calculs.test.ts— Invoicing calculations (gross, VAT)devis-lifecycle.test.ts— Quote lifecyclecrm-pipeline.test.ts— CRM pipelinesecurity-guards.test.ts— Security guardsonboarding.test.ts— Onboarding flowstock.test.ts— Inventory managementtime-logs.test.ts— Time logsleave-workflow.test.ts— Leave workflowfec-export.test.ts— Accounting FEC exportpagination-caps.test.ts— Pagination limits- And 11 others...