Skip to main content
All articles
8 min read

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 -- --coverage

Conclusion

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

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee
Testing a Multi-Tenant SaaS with Vitest | HeartCo