SaaS Forge includes testing infrastructure for unit tests, integration tests, and end-to-end testing.
Testing Stack
- Vitest: Fast unit test runner (Vite-powered)
- React Testing Library: Test React components
- @testing-library/user-event: Simulate user interactions
Current Test Setup
The CMS package includes tests:
# Run CMS tests
pnpm --filter @workspace/cms test
# Watch mode
pnpm --filter @workspace/cms test --watch
# Coverage
pnpm --filter @workspace/cms test --coverage
Adding Tests to Other Packages
1. Install Dependencies
# In a package directory (e.g., packages/auth)
pnpm add -D vitest @testing-library/react @testing-library/jest-dom
2. Create Vitest Config
packages/auth/vitest.config.ts:
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
setupFiles: ["./tests/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
3. Add Test Script
packages/auth/package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
4. Write Tests
packages/auth/tests/session.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { createSession, validateSession } from "../src/session";
describe("Session Management", () => {
let sessionToken: string;
beforeEach(() => {
sessionToken = createSession({ userId: "123" });
});
it("creates a valid session token", () => {
expect(sessionToken).toBeTruthy();
expect(typeof sessionToken).toBe("string");
});
it("validates correct session", () => {
const session = validateSession(sessionToken);
expect(session).toBeTruthy();
expect(session?.userId).toBe("123");
});
it("rejects invalid session", () => {
const session = validateSession("invalid-token");
expect(session).toBeNull();
});
});
Testing React Components
Component Test Example
packages/ui/tests/Button.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "../src/components/shadcn/button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("handles click events", async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByText("Click"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("applies variant classes", () => {
render(<Button variant="destructive">Delete</Button>);
const button = screen.getByText("Delete");
expect(button).toHaveClass("bg-destructive");
});
it("can be disabled", () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByText("Disabled");
expect(button).toBeDisabled();
});
});
Form Component Test
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "../src/components/LoginForm";
describe("LoginForm", () => {
it("validates required fields", async () => {
render(<LoginForm />);
const submitButton = screen.getByRole("button", { name: /sign in/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});
it("submits valid form data", async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(
screen.getByLabelText(/email/i),
"test@example.com"
);
await userEvent.type(
screen.getByLabelText(/password/i),
"password123"
);
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
});
});
});
Testing tRPC Procedures
Mock tRPC Context
// tests/helpers/trpc.ts
import { createTRPCContext } from "@/trpc/init";
export function createMockContext(overrides = {}) {
return {
session: {
user: {
id: "test-user-id",
email: "test@example.com",
name: "Test User",
},
...overrides,
},
};
}
Test Procedures
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { appRouter } from "@/trpc/routers/_app";
import { createMockContext } from "../helpers/trpc";
import db from "@workspace/database/client";
describe("Task Router", () => {
const ctx = createMockContext();
const caller = appRouter.createCaller(ctx);
beforeEach(async () => {
// Seed test data
await db.task.create({
data: {
id: "test-task",
title: "Test Task",
userId: ctx.session.user.id,
},
});
});
afterEach(async () => {
// Clean up
await db.task.deleteMany({
where: { userId: ctx.session.user.id },
});
});
it("lists user tasks", async () => {
const tasks = await caller.task.list();
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("Test Task");
});
it("creates a new task", async () => {
const task = await caller.task.create({
title: "New Task",
description: "Description",
});
expect(task.title).toBe("New Task");
expect(task.userId).toBe(ctx.session.user.id);
});
it("prevents deleting others tasks", async () => {
// Create task for different user
await db.task.create({
data: {
id: "other-task",
title: "Other User Task",
userId: "other-user-id",
},
});
await expect(
caller.task.delete({ id: "other-task" })
).rejects.toThrowError("FORBIDDEN");
});
});
Testing API Routes
import { describe, it, expect } from "vitest";
import { POST } from "@/app/api/contact/route";
describe("POST /api/contact", () => {
it("accepts valid contact form", async () => {
const request = new Request("<http://localhost:3000/api/contact>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "John Doe",
email: "john@example.com",
message: "Hello there!",
}),
});
const response = await POST(request as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
});
it("rejects invalid email", async () => {
const request = new Request("<http://localhost:3000/api/contact>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "John Doe",
email: "invalid-email",
message: "Hello there!",
}),
});
const response = await POST(request as any);
expect(response.status).toBe(400);
});
});
Mocking
Mock External Services
import { vi } from "vitest";
// Mock Notion API
vi.mock("@notionhq/client", () => ({
Client: vi.fn().mockImplementation(() => ({
databases: {
query: vi.fn().mockResolvedValue({
results: [
{
id: "page-1",
properties: {
Name: { title: [{ plain_text: "Test Page" }] },
},
},
],
}),
},
})),
}));
Mock Database
import { vi } from "vitest";
// Mock Prisma
vi.mock("@workspace/database/client", () => ({
default: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
task: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
// In test
import db from "@workspace/database/client";
db.user.findUnique.mockResolvedValue({
id: "123",
email: "test@example.com",
});
Mock Environment Variables
import { beforeEach, afterEach } from "vitest";
describe("Feature with env vars", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
DATABASE_URL: "postgresql://test:test@localhost:5432/test",
NOTION_API_TOKEN: "secret_test_token",
};
});
afterEach(() => {
process.env = originalEnv;
});
it("uses env variables", () => {
// test code
});
});
Integration Tests
Test the full stack together:
import { describe, it, expect } from "vitest";
import { api } from "@/trpc/client";
describe("Task Management Integration", () => {
it("complete task workflow", async () => {
// Create task
const created = await api.task.create.mutate({
title: "Integration Test Task",
});
expect(created.id).toBeTruthy();
// List tasks
const tasks = await api.task.list.query();
expect(tasks).toContainEqual(
expect.objectContaining({ id: created.id })
);
// Update task
const updated = await api.task.update.mutate({
id: created.id,
completed: true,
});
expect(updated.completed).toBe(true);
// Delete task
await api.task.delete.mutate({ id: created.id });
// Verify deletion
const afterDelete = await api.task.list.query();
expect(afterDelete).not.toContainEqual(
expect.objectContaining({ id: created.id })
);
});
});
Test Coverage
Generate coverage reports:
# Run with coverage
pnpm --filter @workspace/cms test --coverage
# Coverage thresholds in vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "json"],
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
});
Best Practices
- Test behavior, not implementation: Focus on what users see/do
- Use data-testid sparingly: Prefer accessible queries (getByRole, getByLabel)
- Keep tests isolated: Each test should be independent
- Mock external dependencies: Don't call real APIs in tests
- Test error cases: Don't just test happy paths
- Use factories: Create test data with factory functions
- Clean up after tests: Reset database, clear mocks
- Run tests in CI: Automate testing on every PR
Continuous Integration
Add to .github/workflows/test.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
node-version: 20
cache: "pnpm"
- run: pnpm install
- run: pnpm test
- run: pnpm build
