Aller au contenu principal
Tous les articles
·8 min de lecture

Tester un SaaS multi-tenant : 7 patterns Vitest indispensables

De l'isolation tenant à la vérification des permissions RBAC, les patterns de test qui rendent un SaaS B2B vraiment fiable. Avec Vitest + tRPC + Prisma.

Le test qu'aucun tutoriel ne montre

La plupart des tutoriels Vitest pour Next.js vous apprennent à tester un composant React isolé. C'est utile, mais c'est 10% de ce qui casse en production.

Les vrais bugs d'un SaaS B2B sont :

  • Un utilisateur de l'organisation A voit les factures de l'organisation B
  • Un rôle MANAGER peut faire une action réservée à ADMIN
  • Un webhook Stripe applique deux fois le même upgrade
  • Un quota freemium n'est pas incrémenté après l'appel

Ce sont des bugs de glue entre auth, RBAC, multi-tenant et logique métier. Ils ne se voient pas dans un test de composant.

Voici 7 patterns Vitest qu'on utilise dans HeartCo pour couvrir ces zones.

Setup minimal

// vitest.config.ts
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "url";
 
export default defineConfig({
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
  resolve: {
    alias: {
      "~/": fileURLToPath(new URL("./src/", import.meta.url)),
    },
  },
});
// src/test/setup.ts
import { vi, beforeEach } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "~/generated/prisma";
 
export const prismaMock = mockDeep<PrismaClient>();
 
vi.mock("~/server/db", () => ({ db: prismaMock }));
 
beforeEach(() => mockReset(prismaMock));

Pattern 1 — Helper de tRPC caller

Pour tester un router, on a besoin d'un caller authentifié. Construisez un helper :

// src/test/trpc-caller.ts
import { appRouter } from "~/server/api/root";
import { createOrgDb } from "~/lib/prisma-org-scope";
import { prismaMock } from "./setup";
 
interface CallerOptions {
  userId?: string;
  organizationId?: string;
  role?: "ADMIN" | "MANAGER" | "COLLABORATOR" | "CLIENT";
}
 
export function createTestCaller(opts: CallerOptions = {}) {
  const userId = opts.userId ?? "user_test";
  const organizationId = opts.organizationId ?? "org_test";
  const role = opts.role ?? "ADMIN";
 
  return appRouter.createCaller({
    db: prismaMock,
    orgDb: createOrgDb(prismaMock, organizationId),
    session: {
      user: { id: userId, organizationId, role, email: "test@test.fr" },
    } as never,
  });
}

Tous vos tests partent de là.

Pattern 2 — Test d'isolation tenant (IDOR)

Le plus critique. Pour chaque router de lecture, vérifiez qu'un user d'une org ne peut PAS accéder aux données d'une autre.

// src/server/api/routers/__tests__/client.test.ts
import { describe, it, expect } from "vitest";
import { TRPCError } from "@trpc/server";
import { createTestCaller } from "~/test/trpc-caller";
import { prismaMock } from "~/test/setup";
 
describe("clientRouter — IDOR protection", () => {
  it("retourne NOT_FOUND si le client appartient à une autre org", async () => {
    prismaMock.client.findFirst.mockResolvedValue(null);
 
    const caller = createTestCaller({ organizationId: "org_a" });
 
    await expect(
      caller.client.getById({ id: "client_from_org_b" }),
    ).rejects.toThrow(
      expect.objectContaining({
        code: "NOT_FOUND",
      }),
    );
 
    // Vérification critique : le where INCLUT bien organizationId
    expect(prismaMock.client.findFirst).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({
          id: "client_from_org_b",
          organizationId: "org_a",
        }),
      }),
    );
  });
});

Pattern 3 — Test de permission RBAC

Un MANAGER ne doit pas pouvoir supprimer une organisation. Un CLIENT ne doit voir aucun router interne.

describe("clientRouter.delete — RBAC", () => {
  it("autorise ADMIN à supprimer un client", async () => {
    prismaMock.client.delete.mockResolvedValue({
      id: "c1",
      organizationId: "org_a",
    } as never);
 
    const caller = createTestCaller({ role: "ADMIN" });
 
    await expect(caller.client.delete({ id: "c1" })).resolves.toMatchObject({
      id: "c1",
    });
  });
 
  it("rejette MANAGER avec FORBIDDEN", async () => {
    const caller = createTestCaller({ role: "MANAGER" });
 
    await expect(caller.client.delete({ id: "c1" })).rejects.toMatchObject({
      code: "FORBIDDEN",
    });
  });
 
  it("rejette CLIENT avec FORBIDDEN", async () => {
    const caller = createTestCaller({ role: "CLIENT" });
 
    await expect(caller.client.delete({ id: "c1" })).rejects.toMatchObject({
      code: "FORBIDDEN",
    });
  });
});

Pattern de table-driven test pour couvrir tous les rôles d'un coup :

const ROLE_MATRIX = [
  { role: "ADMIN", allowed: true },
  { role: "DIRECTION", allowed: true },
  { role: "MANAGER", allowed: false },
  { role: "COLLABORATOR", allowed: false },
  { role: "CLIENT", allowed: false },
] as const;
 
describe.each(ROLE_MATRIX)(
  "clientRouter.delete($role)",
  ({ role, allowed }) => {
    it(allowed ? "autorise" : "rejette", async () => {
      const caller = createTestCaller({ role });
      const promise = caller.client.delete({ id: "c1" });
 
      if (allowed) {
        await expect(promise).resolves.toBeDefined();
      } else {
        await expect(promise).rejects.toMatchObject({ code: "FORBIDDEN" });
      }
    });
  },
);

Pattern 4 — Test de webhook signé (Stripe)

Les webhooks sont des routes publiques. Tester la vérification de signature est crucial.

// src/app/api/webhooks/stripe/__tests__/route.test.ts
import { POST } from "../route";
import { vi } from "vitest";
 
vi.mock("~/lib/stripe", () => ({
  stripe: {
    webhooks: {
      constructEvent: vi.fn(),
    },
  },
}));
 
describe("POST /api/webhooks/stripe", () => {
  it("rejette une signature invalide avec 400", async () => {
    const { stripe } = await import("~/lib/stripe");
    vi.mocked(stripe.webhooks.constructEvent).mockImplementation(() => {
      throw new Error("Invalid signature");
    });
 
    const res = await POST(
      new Request("http://localhost/api/webhooks/stripe", {
        method: "POST",
        headers: { "stripe-signature": "fake" },
        body: JSON.stringify({}),
      }),
    );
 
    expect(res.status).toBe(400);
  });
 
  it("traite checkout.session.completed pour la bonne org", async () => {
    const { stripe } = await import("~/lib/stripe");
    vi.mocked(stripe.webhooks.constructEvent).mockReturnValue({
      type: "checkout.session.completed",
      data: {
        object: {
          metadata: { organizationId: "org_42" },
          subscription: "sub_xyz",
        },
      },
    } as never);
 
    await POST(
      new Request("http://localhost/api/webhooks/stripe", {
        method: "POST",
        headers: { "stripe-signature": "valid" },
        body: "{}",
      }),
    );
 
    expect(prismaMock.subscription.update).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { organizationId: "org_42" },
        data: expect.objectContaining({ plan: "PRO" }),
      }),
    );
  });
});

Pattern 5 — Test d'idempotence

Les webhooks peuvent être livrés deux fois (Stripe retry sur timeout). Votre code doit être idempotent.

it("ne crée pas deux abonnements si le webhook est livré deux fois", async () => {
  const event = {
    id: "evt_123",
    type: "checkout.session.completed",
    data: { object: { metadata: { organizationId: "org_42" } } },
  };
 
  vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(event as never);
  prismaMock.webhookEvent.create.mockResolvedValueOnce({
    id: "evt_123",
  } as never);
  prismaMock.webhookEvent.create.mockRejectedValueOnce(
    Object.assign(new Error("Unique constraint"), { code: "P2002" }),
  );
 
  // Premier appel : OK
  const res1 = await POST(/* req */);
  expect(res1.status).toBe(200);
 
  // Deuxième appel (rejeu) : ignoré, pas d'update DB
  const res2 = await POST(/* req */);
  expect(res2.status).toBe(200);
  expect(prismaMock.subscription.update).toHaveBeenCalledTimes(1);
});

Pattern 6 — Test de freemium guard

describe("invoiceRouter.create — freemium quota", () => {
  it("incrémente le compteur APRÈS le succès de la création", async () => {
    prismaMock.subscription.findFirst.mockResolvedValue({
      plan: "FREE",
      invoicesUsed: 3,
    } as never);
    prismaMock.invoice.create.mockResolvedValue({ id: "inv_1" } as never);
 
    const caller = createTestCaller();
    await caller.invoice.create({ clientId: "c1", lines: [] });
 
    expect(prismaMock.invoice.create).toHaveBeenCalled();
    expect(prismaMock.subscription.update).toHaveBeenCalledWith(
      expect.objectContaining({
        data: { invoicesUsed: { increment: 1 } },
      }),
    );
  });
 
  it("rejette avec FORBIDDEN quand le quota est atteint", async () => {
    prismaMock.subscription.findFirst.mockResolvedValue({
      plan: "FREE",
      invoicesUsed: 5, // limite FREE = 5
    } as never);
 
    const caller = createTestCaller();
    await expect(
      caller.invoice.create({ clientId: "c1", lines: [] }),
    ).rejects.toMatchObject({ code: "FORBIDDEN" });
 
    // Le code N'A PAS appelé create()
    expect(prismaMock.invoice.create).not.toHaveBeenCalled();
  });
 
  it("n'incrémente PAS le compteur si la création échoue", async () => {
    prismaMock.subscription.findFirst.mockResolvedValue({
      plan: "PRO",
      invoicesUsed: 50,
    } as never);
    prismaMock.invoice.create.mockRejectedValue(new Error("DB down"));
 
    const caller = createTestCaller();
    await expect(
      caller.invoice.create({ clientId: "c1", lines: [] }),
    ).rejects.toThrow();
 
    expect(prismaMock.subscription.update).not.toHaveBeenCalled();
  });
});

Pattern 7 — Test E2E des invariants critiques

Pour les chemins critiques (paiement, suppression de compte), un test E2E Playwright + DB de test vaut mieux que 10 tests unitaires.

// e2e/billing-flow.spec.ts
import { test, expect } from "@playwright/test";
 
test("upgrade FREE → PRO via Stripe Checkout", async ({ page }) => {
  await page.goto("/dashboard/billing");
 
  // Clic sur "Passer au plan PRO"
  await page.getByRole("button", { name: "Passer au plan PRO" }).click();
 
  // Page Stripe Checkout
  await expect(page).toHaveURL(/checkout\.stripe\.com/);
  await page.fill('[name="cardnumber"]', "4242 4242 4242 4242");
  await page.fill('[name="exp-date"]', "12/30");
  await page.fill('[name="cvc"]', "123");
  await page.fill('[name="billing-name"]', "Test User");
  await page.getByRole("button", { name: "Payer" }).click();
 
  // Retour sur le dashboard, plan upgradé
  await expect(page).toHaveURL(/dashboard\/billing/);
  await expect(page.getByText("Plan PRO")).toBeVisible();
  await expect(page.getByText("100 factures/mois")).toBeVisible();
});

Le test va jusqu'au bout du flow : webhook reçu, DB mise à jour, UI rafraîchie. Si quoi que ce soit casse dans cette chaîne, le test échoue.

La règle des 80/20

Vous n'avez pas besoin de 100% de coverage. Vous avez besoin de couvrir les 20% du code où sont les 80% des bugs en prod :

  • Auth & multi-tenant → tests unitaires obligatoires pour chaque router
  • Webhooks externes → tests d'idempotence + signature
  • Freemium / facturation → tests check-before / increment-after
  • Permissions critiques → tests RBAC en table-driven
  • Flows monétisés → E2E Playwright (signup → paiement → upgrade)

Le reste (composants UI isolés, helpers purs) peut attendre.

Commandes utiles

# Tous les tests
pnpm test
 
# Tests sécurité uniquement (rapide en CI)
pnpm test:security
 
# Mode watch pendant le dev
pnpm test:watch
 
# Coverage report
pnpm test -- --coverage

Conclusion

Tester un SaaS multi-tenant n'est pas plus dur — c'est juste différent. Vous testez moins de unit logic et plus de glue : qui peut faire quoi, sur quelles données, dans quelles conditions.

HeartCo Starter inclut 70+ tests pré-écrits pour ces patterns. Vous en héritez gratuitement dès le clone — votre couverture des chemins critiques est à 80% avant même votre première feature.

Partager
Tester un SaaS multi-tenant : 7 patterns Vitest indispensables | HeartCo Dev Blog