Unit Tests

Vitest setup, React Testing Library patterns, and test utilities for testing components, hooks, and API routes

Kit ships with 58 unit test files containing 800+ tests that cover components, hooks, API routes, payment webhooks, credit system logic, and security utilities. All tests run in a happy-dom environment with MSW intercepting every API call — no database or external services required.

Configuration

The Vitest configuration defines the test environment, setup file, environment variables, and coverage settings:
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    env: {
      // CRITICAL: Disable Repository mocks in unit tests
      // Unit tests mock Prisma directly and need full control over data flow
      // This flag tells Repository.isTestMode() to return false, allowing Prisma mocks to work
      DISABLE_REPOSITORY_MOCKS: 'true',

      // CRITICAL: Enable credit system in unit tests
      // Allows testing credit deduction, initialization, tier adjustment, etc.
      // E2E tests do NOT set this flag (credit system disabled, no database)
      ENABLE_CREDIT_SYSTEM_IN_TESTS: 'true',
    },
    exclude: ['node_modules/**', '.next/**', 'e2e/**'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '*.config.ts',
        '**/*.d.ts',
        '.next/',
        'src/components/ui/**', // shadcn/ui components ausschließen
      ],
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
})
Key settings:
  • happy-dom environment provides a lightweight DOM simulation (faster than jsdom)
  • DISABLE_REPOSITORY_MOCKS — Unit tests mock Prisma directly and need full control over the data layer. This flag disables the higher-level Repository mocks that E2E tests use
  • ENABLE_CREDIT_SYSTEM_IN_TESTS — Activates credit deduction, initialization, and tier adjustment logic so you can test the full credit lifecycle
  • Coverage excludes node_modules/, test utilities, config files, and shadcn/ui components (generated code)

Test Setup

The setup file runs before every test suite. It starts the MSW server, extends Vitest matchers, and mocks framework dependencies:
src/test/setup.ts — MSW Lifecycle & Matchers
import '@testing-library/jest-dom'
import { expect, afterEach, vi, beforeAll, afterAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
import { server } from '@/mocks/server'

// Extend Vitest matchers
expect.extend(matchers)

// Start MSW server before all tests
beforeAll(() => {
  server.listen({
    onUnhandledRequest: 'warn',
  })
})

// Reset handlers after each test
afterEach(() => {
  cleanup()
  server.resetHandlers()
})

// Clean up after all tests
afterAll(() => {
  server.close()
})
The MSW lifecycle ensures every test starts with a clean handler state. The onUnhandledRequest: 'warn' setting catches missing handlers early during development.
src/test/setup.ts — Framework Mocks
// Mock Next.js Router
vi.mock('next/navigation', () => ({
  useRouter() {
    return {
      push: vi.fn(),
      replace: vi.fn(),
      prefetch: vi.fn(),
      back: vi.fn(),
      pathname: '/',
      query: {},
      asPath: '/',
    }
  },
  usePathname() {
    return '/'
  },
  useSearchParams() {
    return new URLSearchParams()
  },
}))

// Mock Clerk v6 - Updated for async auth()
vi.mock('@clerk/nextjs', () => ({
  auth: () =>
    Promise.resolve({
      userId: 'test-user-id',
      protect: () => Promise.resolve(),
    }),
  currentUser: () =>
    Promise.resolve({
      id: 'test-user-id',
      firstName: 'Test',
      lastName: 'User',
      emailAddresses: [{ emailAddress: 'test@example.com' }],
    }),
  useAuth: () => ({
    userId: 'test-user-id',
    sessionId: 'test-session-123',
    isLoaded: true,
    isSignedIn: true,
    getToken: async () => 'test-token-123',
    signOut: async () => {},
  }),
  useUser: () => ({
    user: {
      id: 'test-user-id',
      firstName: 'Test',
      lastName: 'User',
    },
    isLoaded: true,
    isSignedIn: true,
  }),
  ClerkProvider: ({ children }: { children: React.ReactNode }) => children,
  SignIn: () => null,
  SignUp: () => null,
  UserButton: () => null,
}))

// Mock Sonner Toast
vi.mock('sonner', () => ({
  toast: {
    success: vi.fn(),
    error: vi.fn(),
    info: vi.fn(),
    warning: vi.fn(),
  },
  Toaster: () => null,
}))
Three framework mocks are configured globally:
  1. Next.js Router — Mocks useRouter, usePathname, and useSearchParams so components using navigation render without errors
  2. Clerk v6 — Mocks both server (auth(), currentUser()) and client (useAuth, useUser) APIs. The auth() mock returns a Promise to match Clerk v6's async signature
  3. Sonner — Mocks toast notifications so you can assert on toast.success() and toast.error() calls

Running Tests

CommandDescription
pnpm testWatch mode — re-runs affected tests on file change
pnpm test:unitSingle run — executes all tests once and exits
pnpm test:uiVisual UI — opens Vitest's browser-based test explorer
pnpm test:coverageCoverage report — generates HTML, JSON, and text reports

Test Organization

Tests live in co-located __tests__/ directories next to the code they test:
apps/boilerplate/src/
  lib/
    payments/
      __tests__/
        config.test.ts
        lemonsqueezy-client.test.ts
        subscriptions.test.ts
      config.ts
      lemonsqueezy-client.ts
      subscriptions.ts
  components/
    credits/
      __tests__/
        low-credit-banner.test.tsx
        usage-counter.test.tsx
      low-credit-banner.tsx
      usage-counter.tsx
Naming convention: <module-name>.test.ts for logic, <component-name>.test.tsx for React components.

Coverage by Area

AreaTest FilesWhat is Tested
Credit system10Auto-reset, concurrent ops, tier adjustment, transaction safety, trial helpers
Webhooks9Billing cycle, state transitions, subscription events (create, update, cancel, pause, expire)
AI integration6Service, provider factory, rate limiter, feature flags, RAG, route guards
Security4Rate limiter, CORS, sanitization, security headers
Payments3Config, LemonSqueezy client, subscriptions
Pricing3Config, user status, variant ID lookup
Components2Low-credit banner, usage counter
Other21API schemas, checkout, navigation, query prefetch, AI hooks, dashboard

Testing Patterns

Component Tests

Use render, screen, and userEvent from React Testing Library:
typescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LowCreditBanner } from '../low-credit-banner'

describe('LowCreditBanner', () => {
  it('shows warning when credits are low', () => {
    render(<LowCreditBanner credits={5} threshold={10} />)

    expect(screen.getByRole('alert')).toBeInTheDocument()
    expect(screen.getByText(/5 credits remaining/i)).toBeInTheDocument()
  })

  it('hides when dismissed', async () => {
    const user = userEvent.setup()
    render(<LowCreditBanner credits={5} threshold={10} />)

    await user.click(screen.getByRole('button', { name: /dismiss/i }))

    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  })
})

Hook Tests

Wrap hooks in a QueryClientProvider using the test utility wrapper:
typescript
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useCreditDeduction } from '../use-credit-deduction'

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  })
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

describe('useCreditDeduction', () => {
  it('deducts credits on mutation', async () => {
    const { result } = renderHook(() => useCreditDeduction(), {
      wrapper: createWrapper(),
    })

    result.current.mutate({ amount: 10, reason: 'ai-chat' })

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true)
    })
  })
})

API Route Tests

Test API route handlers directly with fetch — MSW intercepts the request and returns mock data:
typescript
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'

describe('POST /api/contact', () => {
  it('returns success for valid input', async () => {
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'Jane Doe',
        email: 'jane@example.com',
        subject: 'Hello',
        message: 'This is a test message for the contact form.',
      }),
    })

    const data = await response.json()
    expect(data.success).toBe(true)
  })

  it('returns 400 for invalid body', async () => {
    // Override default handler for this test only
    server.use(
      http.post('/api/contact', () => {
        return HttpResponse.json(
          { error: 'Validation failed' },
          { status: 400 }
        )
      })
    )

    const response = await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify({}),
    })

    expect(response.status).toBe(400)
  })
})

Feature Flag Tests

Use vi.mocked() with dynamic imports to test feature flag behavior:
typescript
import { vi } from 'vitest'

describe('AI Feature Flags', () => {
  afterEach(() => {
    vi.resetModules()
    vi.restoreAllMocks()
  })

  it('disables RAG when flag is off', async () => {
    vi.stubEnv('NEXT_PUBLIC_AI_RAG_CHAT_ENABLED', 'false')

    // Dynamic import to pick up new env value
    const { isRAGEnabled } = await import('@/lib/ai/feature-flags')

    expect(isRAGEnabled()).toBe(false)
  })
})

Environment Variable Tests

Use the resetModules pattern for testing code that reads process.env at module load time:
typescript
describe('Config', () => {
  const originalEnv = process.env

  beforeEach(() => {
    vi.resetModules()
    process.env = { ...originalEnv }
  })

  afterEach(() => {
    process.env = originalEnv
  })

  it('uses credit_based pricing when configured', async () => {
    process.env.NEXT_PUBLIC_PRICING_MODEL = 'credit_based'
    const { pricingModel } = await import('@/lib/config')
    expect(pricingModel).toBe('credit_based')
  })
})

Test Utilities

Factory Functions

Kit includes factory functions in apps/boilerplate/src/test/factories/ that generate realistic test data:
Factory FileExports
user.factory.tscreateMockFreeUser, createMockProUser, createMockEnterpriseUser, createMockTrialUser, createMockLockedUser
subscription.factory.tscreateMockActiveSubscription, createMockTrialSubscription, createMockCancelledSubscription, createMockPausedSubscription, createMockExpiredSubscription
file.factory.tscreateMockFile, createMockPdfFile, createMockImageFile, createMockLargeFile, createMockFileList
ai-conversation.factory.tscreateMockConversation, createMockMessage
ai-usage.factory.tscreateMockUsageRecord (FAQ, chat, completion, streaming, embeddings)
typescript
import {
  createMockProUser,
  createMockActiveSubscription,
} from '@/test/factories'

const user = createMockProUser()
const subscription = createMockActiveSubscription()

TanStack Query Wrapper

Many hooks need a QueryClientProvider. Create a reusable wrapper:
typescript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export function createQueryWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  })

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
Setting retry: false and gcTime: 0 prevents cached data and retry delays from making tests flaky.

Writing Your First Test

1

Create the test file

Create a __tests__/ directory next to the component and add a test file:
apps/boilerplate/src/components/dashboard/
  __tests__/
    stats-card.test.tsx    <-- new file
  stats-card.tsx
2

Write the test

typescript
import { render, screen } from '@testing-library/react'
import { StatsCard } from '../stats-card'

describe('StatsCard', () => {
  it('renders the title and value', () => {
    render(<StatsCard title="Total Users" value={1234} />)

    expect(screen.getByText('Total Users')).toBeInTheDocument()
    expect(screen.getByText('1,234')).toBeInTheDocument()
  })

  it('shows trend indicator when provided', () => {
    render(<StatsCard title="Revenue" value={5000} trend="+12%" />)

    expect(screen.getByText('+12%')).toBeInTheDocument()
  })
})
3

Run the test

bash
cd apps/boilerplate && pnpm test src/components/dashboard/__tests__/stats-card.test.tsx
Or use watch mode for continuous feedback:
bash
pnpm test -- --watch

Coverage

Coverage is generated with the v8 provider (V8's built-in code coverage, faster than Istanbul):
bash
pnpm test:coverage
Reports are generated in three formats:
FormatOutputPurpose
textTerminalQuick overview during development
jsoncoverage/coverage-final.jsonCI integration and tooling
htmlcoverage/index.htmlDetailed per-file interactive report

Excluded from Coverage

The following paths are excluded because they contain generated or third-party code:
  • node_modules/ — Dependencies
  • apps/boilerplate/src/test/ — Test utilities themselves
  • *.config.ts — Build configuration
  • **/*.d.ts — Type declarations
  • .next/ — Build output
  • apps/boilerplate/src/components/ui/** — shadcn/ui generated components

Key Files

FilePurpose
apps/boilerplate/vitest.config.tsTest environment, setup file, coverage, aliases
apps/boilerplate/src/test/setup.tsMSW lifecycle, jest-dom matchers, framework mocks
apps/boilerplate/src/test/factories/Factory functions for generating test data
apps/boilerplate/src/mocks/server.tsNode.js MSW server used by all unit tests
apps/boilerplate/src/mocks/handlers.tsDefault handlers for all API endpoints