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 to src/)
  • 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

SuiteCoverage
MarketingHomepage, pricing, FAQ, terms, privacy, 404 page
AuthSign-in fields, invalid credentials error, protected route redirects, 2FA route
APIStripe webhook endpoint, API key auth (401/403), security headers
Docs7 doc pages load, navigation present, 404 for unknown slugs
AccessibilitySkip-to-content link, lang attribute, image alt text, form labels, heading hierarchy
SEOMeta 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

FileScope
src/app/error.tsxGlobal error boundary (all routes)
src/app/(app)/w/[workspaceSlug]/error.tsxWorkspace-scoped errors
src/app/not-found.tsx404 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:

PageSkeleton
Dashboard4 KPI cards + chart + activity feed
ProductsTab bar + search bar + 5-row table
Product DetailTitle + description + metadata + comments
MembersTitle + invite button + 3-row table
BillingPlan card + usage meters + plan comparison
Settings4 form sections
Audit LogFilter 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:

  1. Unit test the server action logic (validation, business rules) in src/__tests__/
  2. E2E test the critical user flow in e2e/
  3. Run both before merging: pnpm test && pnpm test:e2e