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 -- --coverageConclusion
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.
Articles connexes
Architecture multi-tenant avec Prisma et tRPC : isolation des données garantie
Comment implémenter une isolation multi-tenant robuste dans Next.js avec Prisma $extends et tRPC — le pattern qui rend les fuites de données impossibles.
LiretRPC v11 : une API full type-safe sans code generation
Zéro schéma GraphQL, zéro codegen, zéro runtime overhead — comment tRPC v11 change la façon de construire des API pour les SaaS Next.js.
LireSécuriser un SaaS multi-tenant avec Prisma : le guide ultime
Isolation des données par organisation, filtres automatiques, et patterns pour éviter les fuites de données entre tenants.
Lire