Testing
The project uses Vitest for both API and frontend tests. Two test projects run from a single root config – API tests in Node, frontend tests in Happy DOM.
Configuration
The root config defines both projects:
// vitest.config.ts
export default defineConfig({
test: {
projects: ["apps/api", "apps/app"],
},
});apps/api has its own vitest.config.ts; apps/app uses an inline test block in vite.config.ts:
| Project | Environment | Setup file |
|---|---|---|
apps/api | Node (default) | – |
apps/app | happy-dom | vitest.setup.ts |
The app setup file registers jest-dom matchers like toBeInTheDocument():
// apps/app/vitest.setup.ts
import "@testing-library/jest-dom/vitest";Running Tests
bun test # All projects, watch mode
bun test --run # Single run (no watch)
bun test --project @repo/api # API tests only
bun test --project @repo/app # Frontend tests only
bun test billing # Filter by filenameFile Conventions
- Test files live next to the code they test –
billing.ts→billing.test.ts - Import everything from
vitest, not globals:
import { describe, expect, it, vi } from "vitest";Testing tRPC Procedures
Use createCallerFactory to invoke procedures directly without HTTP. Build a minimal context mock with only the fields the procedure accesses:
// apps/api/routers/billing.test.ts
import { describe, expect, it, vi } from "vitest";
import type { TRPCContext } from "../lib/context";
import { createCallerFactory } from "../lib/trpc";
import { billingRouter } from "./billing";
const createCaller = createCallerFactory(billingRouter);
function testCtx({
userId = "user-1",
activeOrgId = undefined as string | undefined,
subscription = undefined as Record<string, unknown> | undefined,
} = {}) {
const ctx: TRPCContext = {
req: new Request("http://localhost"),
info: {} as TRPCContext["info"],
session: {
id: "s-1",
createdAt: new Date(),
updatedAt: new Date(),
userId,
expiresAt: new Date(Date.now() + 60_000),
token: "token",
activeOrganizationId: activeOrgId,
},
user: {
id: userId,
createdAt: new Date(),
updatedAt: new Date(),
email: "test@example.com",
emailVerified: true,
name: "Test User",
},
db: {
query: {
subscription: {
findFirst: vi.fn().mockResolvedValue(subscription),
},
},
} as unknown as TRPCContext["db"],
dbDirect: {} as TRPCContext["dbDirect"],
cache: new Map(),
env: {} as TRPCContext["env"],
};
return ctx;
}
describe("billing.subscription", () => {
it("returns free plan defaults when no subscription exists", async () => {
const result = await createCaller(testCtx()).subscription();
expect(result).toEqual({
plan: "free",
status: null,
periodEnd: null,
cancelAtPeriodEnd: false,
limits: { members: 1 },
});
});
it("throws on unknown plan name", async () => {
await expect(
createCaller(
testCtx({ subscription: { plan: "enterprise", status: "active" } }),
).subscription(),
).rejects.toThrow('Unknown plan "enterprise"');
});
});Key points:
createCallerFactory(router)from@trpc/server– calls procedures in-process, no network layer- Cast partial DB mocks with
as unknown as TRPCContext["db"]– only stub the methods your procedure actually calls - Use
vi.fn().mockResolvedValue()for async Drizzle query methods
Testing Utility Functions
Pure functions need no mocking – just import and assert:
// apps/app/lib/errors.test.ts
import { describe, expect, it } from "vitest";
import { getErrorMessage, isUnauthenticatedError } from "./errors";
describe("getErrorMessage", () => {
it("extracts message from Error instances", () => {
expect(getErrorMessage(new Error("Something broke"))).toBe(
"Something broke",
);
});
it("returns fallback for unknown shapes", () => {
expect(getErrorMessage(null)).toBe("An unexpected error occurred");
});
});Testing Query Options
Test TanStack Query option factories by inspecting query keys. Use a real QueryClient with retries disabled to test cache helpers:
// apps/app/lib/queries/session.test.ts
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { getCachedSession, isAuthenticated, sessionQueryKey } from "./session";
function createQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}
describe("isAuthenticated", () => {
it("returns true when both user and session exist", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(sessionQueryKey, {
user: { id: "user-1", email: "test@example.com" },
session: { id: "session-1", expiresAt: new Date() },
});
expect(isAuthenticated(queryClient)).toBe(true);
});
it("returns false when no session data cached", () => {
expect(isAuthenticated(createQueryClient())).toBe(false);
});
});Testing React Components
The app project includes React Testing Library with Happy DOM. Components render in a simulated DOM:
// apps/app/components/example.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MyComponent } from "./my-component";
describe("MyComponent", () => {
it("renders the label", () => {
render(<MyComponent label="Hello" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("calls onClick when button is pressed", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<MyComponent label="Click me" onClick={onClick} />);
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledOnce();
});
});TIP
Use userEvent over fireEvent for user interactions – it simulates real browser behavior (focus, keyboard events, pointer events) rather than dispatching synthetic events.
Mocking
Function mocks
const fn = vi.fn();
fn.mockReturnValue(42);
fn.mockResolvedValue({ data: "ok" }); // async
fn.mockImplementation((x) => x + 1);Partial object mocks
Cast partial mocks when you only need a subset of a typed interface:
const db = {
query: {
user: { findFirst: vi.fn().mockResolvedValue({ id: "user-1" }) },
},
} as unknown as TRPCContext["db"];Module mocks
vi.mock(import("./some-module.js"), () => ({
myFunction: vi.fn().mockReturnValue("mocked"),
}));For partial module mocks that keep the original implementation:
vi.mock(import("./some-module.js"), async (importOriginal) => {
const mod = await importOriginal();
return { ...mod, myFunction: vi.fn() };
});WARNING
Module mocks are hoisted – they run before imports regardless of where you write them. See Vitest mocking docs for details.
Where Tests Live
apps/
├── api/
│ └── routers/
│ └── billing.test.ts # tRPC procedure tests
└── app/
└── lib/
├── errors.test.ts # utility function tests
└── queries/
├── billing.test.ts # query option tests
└── session.test.ts # cache helper testsPlace test files next to the source they test. No separate __tests__ directories.
Console Output
Tests must never output anything to the console (console.log, console.warn, console.error) except for the actual error that has to be fixed. Nothing else can ever be in the console, only errors.
If a test intentionally triggers an error (e.g., testing an Error Boundary), you should suppress the expected console output by spying on the console method (vi.spyOn(console, "error").mockImplementation(() => {})) within your test. The project's testing setup files also proactively suppress known expected React boundary errors.
Keeping the test suite output clean ensures that real failures are easy to spot and diagnose.