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: "[email protected]",
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: "[email protected]" },
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.