Testing a Multi-Tenant SaaS: 7 Essential Vitest Patterns
From tenant isolation to RBAC permission checks, the test patterns that make a B2B SaaS genuinely reliable. With Vitest, tRPC, and Prisma.
The test no tutorial shows you
Most Vitest tutorials for Next.js teach you to test an isolated React component. That's useful, but it's 10% of what actually breaks in production.
The real bugs in a B2B SaaS look like this:
- A user from organization A sees organization B's invoices
- A MANAGER role can perform an action reserved for ADMIN
- A Stripe webhook applies the same upgrade twice
- A freemium quota doesn't get incremented after the call
These are glue bugs, between auth, RBAC, multi-tenancy, and business logic. They never show up in a component test.
Here are 7 Vitest patterns used inside HeartCo to cover these areas.
Minimal setup
// 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: a tRPC caller helper
To test a router, you need an authenticated caller. Build a helper for it:
// 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,
});
}Every test starts from here.
Pattern 2: tenant isolation test (IDOR)
The most critical one. For every read-side router, verify that a user from one org can NOT reach another org's data.
// 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("returns NOT_FOUND when the client belongs to another 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",
}),
);
// Critical check: the where actually INCLUDES organizationId
expect(prismaMock.client.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: "client_from_org_b",
organizationId: "org_a",
}),
}),
);
});
});Astuce
Don't just test that it throws: check how the where clause is built. That's where copy-paste bugs hide.
Pattern 3: RBAC permission test
A MANAGER shouldn't be able to delete an organization. A CLIENT shouldn't see any internal router at all.
describe("clientRouter.delete: RBAC", () => {
it("allows ADMIN to delete a 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("rejects MANAGER with FORBIDDEN", async () => {
const caller = createTestCaller({ role: "MANAGER" });
await expect(caller.client.delete({ id: "c1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rejects CLIENT with FORBIDDEN", async () => {
const caller = createTestCaller({ role: "CLIENT" });
await expect(caller.client.delete({ id: "c1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
});Table-driven test pattern to cover every role at once:
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 ? "allows" : "rejects", 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: signed webhook test (Stripe)
Webhooks are public routes. Testing signature verification is critical.
// 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("rejects an invalid signature with 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("processes checkout.session.completed for the right 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: idempotency test
Webhooks can be delivered twice (Stripe retries on timeout). Your code has to be idempotent.
it("does not create two subscriptions when the webhook fires twice", 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" }),
);
// First call: OK
const res1 = await POST(/* req */);
expect(res1.status).toBe(200);
// Second call (a replay): ignored, no DB update
const res2 = await POST(/* req */);
expect(res2.status).toBe(200);
expect(prismaMock.subscription.update).toHaveBeenCalledTimes(1);
});Pattern 6: freemium guard test
describe("invoiceRouter.create: freemium quota", () => {
it("increments the counter AFTER the creation succeeds", 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("rejects with FORBIDDEN when the quota is reached", async () => {
prismaMock.subscription.findFirst.mockResolvedValue({
plan: "FREE",
invoicesUsed: 5, // FREE plan limit = 5
} as never);
const caller = createTestCaller();
await expect(
caller.invoice.create({ clientId: "c1", lines: [] }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
// The code did NOT call create()
expect(prismaMock.invoice.create).not.toHaveBeenCalled();
});
it("does NOT increment the counter if creation fails", 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: E2E test for critical invariants
For critical paths (payment, account deletion), one Playwright E2E test against a real test database beats 10 unit tests.
// 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");
// Click "Upgrade to PRO"
await page.getByRole("button", { name: "Upgrade to PRO" }).click();
// Stripe Checkout page
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: "Pay" }).click();
// Back on the dashboard, plan upgraded
await expect(page).toHaveURL(/dashboard\/billing/);
await expect(page.getByText("PRO Plan")).toBeVisible();
await expect(page.getByText("100 invoices/month")).toBeVisible();
});The test runs the whole flow: webhook received, DB updated, UI refreshed. If anything in that chain breaks, the test fails.
The 80/20 rule
You don't need 100% coverage. You need to cover the 20% of the code where 80% of production bugs live:
- Auth & multi-tenancy → unit tests required for every router
- External webhooks → idempotency + signature tests
- Freemium / billing → check-before / increment-after tests
- Critical permissions → table-driven RBAC tests
- Monetized flows → Playwright E2E (signup → payment → upgrade)
Everything else (isolated UI components, pure helpers) can wait.
Useful commands
# All tests
pnpm test
# Security tests only (fast in CI)
pnpm test:security
# Watch mode during development
pnpm test:watch
# Coverage report
pnpm test -- --coverageConclusion
Testing a multi-tenant SaaS isn't harder, it's just different. You test less pure unit logic and more glue: who can do what, on which data, under which conditions.
HeartCo Starter ships 70+ pre-written tests for these exact patterns. You inherit them for free from the moment you clone: your critical-path coverage is at 80% before you write your first feature.
Go further
Related articles
Testing Multi-Tenant Isolation in a SaaS
Twelve test families pulled from real code: IDOR, webhooks, rate limiting, sessions. What they prove, and what they don't.
ReadMulti-Tenant Isolation with Prisma and tRPC: What the Automatic Filter Covers, and What Doesn't
Isolate each organization's data in Next.js with Prisma $extends and tRPC: what the automatic filter covers, and what stays explicit.
ReadtRPC v11: A Fully Type-Safe API Without Code Generation
Zero GraphQL schema, zero codegen, zero runtime overhead: how tRPC v11 changes the way you build APIs for Next.js SaaS products.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.