Aller au contenu principal

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.ts

Conventions

ConventionDetail
LocationColocated: src/lib/foo/__tests__/foo.test.ts or src/lib/foo/foo.test.ts
Routerssrc/server/api/routers/__tests__/
Securitysrc/__tests__/security/
DBMock Prisma — never a real DB in unit tests
Globalsdescribe, 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

ProjectDescriptionAuth
setupGlobal login, saves the auth state
publicPublic pages (smoke, auth, portal)No
authenticatedDashboard, invoicing, bankingYes (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:security

Verifies that the tRPC procedures use the right guards:

  • staffProcedure at minimum for internal routes
  • requirePermission() for sensitive actions
  • adminProcedure for critical settings

Full audit

pnpm test:security:full

Extended tests:

  • IDOR: cross-organization access attempt
  • Crypto: HMAC verification, token encryption
  • CVE: known protections (middleware bypass, etc.)
  • Middleware: CSP, headers, rate limiting

Fixing conventions

RuleDetail
Fix the codeNEVER edit the tests to make them pass
Max attempts5 per test before marking it "blocked"
LoggingLog the fixes in test-reports/corrections.log
Pre-existing failuresNote 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 lifecycle
  • crm-pipeline.test.ts — CRM pipeline
  • security-guards.test.ts — Security guards
  • onboarding.test.ts — Onboarding flow
  • stock.test.ts — Inventory management
  • time-logs.test.ts — Time logs
  • leave-workflow.test.ts — Leave workflow
  • fec-export.test.ts — Accounting FEC export
  • pagination-caps.test.ts — Pagination limits
  • And 11 others...

◀ Deployment · Contents · FAQ ▶