Testing
Vitest covers pure logic; Playwright covers sign-in, workspace pickers, and billing flows that break when routes move.
Running Tests
# Unit tests (Vitest)
pnpm test # Run once
pnpm test:watch # Watch mode
# E2E tests (Playwright)
pnpm test:e2e # Run all E2E tests
pnpm test:e2e:ui # Run with Playwright UI (interactive)
Before running E2E tests for the first time, install the browser binaries:
npx playwright install
Unit Tests (Vitest)
Unit tests live in src/__tests__/ or alongside the code they test.
src/
├── __tests__/
│ ├── utils.test.ts # Utility function tests
│ └── docs-config.test.ts # Configuration validation tests
├── lib/
│ └── your-module.test.ts # Co-located unit tests
└── ...
Writing a Unit Test
import { describe, it, expect } from "vitest";
import { cn } from "@/lib/utils";
describe("cn utility", () => {
it("merges class names", () => {
expect(cn("px-2", "py-1")).toBe("px-2 py-1");
});
it("deduplicates conflicting Tailwind classes", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
});
});
Mocking Supabase in Tests
Server actions interact with Supabase and require mocking:
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(() => ({
from: vi.fn(() => ({
select: vi.fn().mockResolvedValue({ data: [], error: null }),
insert: vi.fn().mockResolvedValue({ data: null, error: null }),
})),
})),
}));
Testing Zod Schemas
import { describe, it, expect } from "vitest";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1).max(100),
status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]),
});
describe("schema", () => {
it("accepts valid input", () => {
expect(schema.safeParse({ name: "Widget", status: "ACTIVE" }).success).toBe(true);
});
it("rejects empty name", () => {
expect(schema.safeParse({ name: "", status: "ACTIVE" }).success).toBe(false);
});
});
Vitest Configuration
The config is in vitest.config.ts:
- Path aliases matching
tsconfig.json(@/maps tosrc/) - Node environment for server-side tests
- Global test functions (
describe,it,expect)
For browser-based component testing, add @testing-library/react:
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
E2E Tests (Playwright)
E2E tests live in the e2e/ directory and test the app through a real browser.
e2e/
├── marketing.spec.ts # Landing, pricing, FAQ, docs, SEO
├── auth-flow.spec.ts # Sign-in, sign-up, protected routes, 2FA
├── api.spec.ts # API endpoints, security headers
├── docs.spec.ts # Documentation pages load correctly
└── accessibility.spec.ts # a11y checks (skip link, alt text, labels)
What's Tested
| Suite | Coverage |
|---|---|
| Marketing | Homepage, pricing, FAQ, terms, privacy, 404 page |
| Auth | Sign-in fields, invalid credentials error, protected route redirects, 2FA route |
| API | Stripe webhook endpoint, API key auth (401/403), security headers |
| Docs | 7 doc pages load, navigation present, 404 for unknown slugs |
| Accessibility | Skip-to-content link, lang attribute, image alt text, form labels, heading hierarchy |
| SEO | Meta description, Open Graph tags, robots.txt, sitemap.xml, manifest |
Writing an E2E Test
import { test, expect } from "@playwright/test";
test("homepage loads with hero section", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/SaaSForge/);
await expect(page.locator("h1").first()).toBeVisible();
});
Testing API Endpoints
test("API requires authentication", async ({ request }) => {
const response = await request.get("/api/v1/products");
expect(response.status()).toBe(401);
});
Playwright Configuration
The config is in playwright.config.ts:
- Tests run against
http://localhost:3000 - Auto-starts the dev server via
pnpm dev - Generates an HTML report on failure
- Retries twice in CI
Error Handling
SaaSForge Core includes error boundaries and loading states for a polished user experience:
Error Boundaries
| File | Scope |
|---|---|
src/app/error.tsx | Global error boundary (all routes) |
src/app/(app)/w/[workspaceSlug]/error.tsx | Workspace-scoped errors |
src/app/not-found.tsx | 404 page |
Error boundaries catch unhandled exceptions and show a "Something went wrong" card with a retry button.
Loading States
Every workspace page has a loading.tsx skeleton:
| Page | Skeleton |
|---|---|
| Dashboard | 4 KPI cards + chart + activity feed |
| Products | Tab bar + search bar + 5-row table |
| Product Detail | Title + description + metadata + comments |
| Members | Title + invite button + 3-row table |
| Billing | Plan card + usage meters + plan comparison |
| Settings | 4 form sections |
| Audit Log | Filter bar + 5-row table |
Loading skeletons match the layout of the actual page for a seamless transition.
Adding Tests for Your Features
When you add a new feature:
- Unit test the server action logic (validation, business rules) in
src/__tests__/ - E2E test the critical user flow in
e2e/ - Run both before merging:
pnpm test && pnpm test:e2e