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 useENABLE_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)
Both unit tests and E2E tests run with
NODE_ENV=test, but they need different credit system behavior: unit tests should exercise the full credit lifecycle (deduction, reset, tier adjustment), while E2E tests should skip credit operations (no database writes needed, just UI testing). The ENABLE_CREDIT_SYSTEM_IN_TESTS flag is set in vitest.config.ts but NOT in playwright.config.ts, giving each test type the correct behavior. Apply this same pattern (ENABLE_{FEATURE}_IN_TESTS) when any feature needs different behavior between unit and E2E tests.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:
- Next.js Router — Mocks
useRouter,usePathname, anduseSearchParamsso components using navigation render without errors - Clerk v6 — Mocks both server (
auth(),currentUser()) and client (useAuth,useUser) APIs. Theauth()mock returns a Promise to match Clerk v6's async signature - Sonner — Mocks toast notifications so you can assert on
toast.success()andtoast.error()calls
Running Tests
| Command | Description |
|---|---|
pnpm test | Watch mode — re-runs affected tests on file change |
pnpm test:unit | Single run — executes all tests once and exits |
pnpm test:ui | Visual UI — opens Vitest's browser-based test explorer |
pnpm test:coverage | Coverage report — generates HTML, JSON, and text reports |
pnpm test:ui opens an interactive browser interface where you can filter tests, view component output, and inspect console logs. It is the fastest way to debug a failing test.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
| Area | Test Files | What is Tested |
|---|---|---|
| Credit system | 10 | Auto-reset, concurrent ops, tier adjustment, transaction safety, trial helpers |
| Webhooks | 9 | Billing cycle, state transitions, subscription events (create, update, cancel, pause, expire) |
| AI integration | 6 | Service, provider factory, rate limiter, feature flags, RAG, route guards |
| Security | 4 | Rate limiter, CORS, sanitization, security headers |
| Payments | 3 | Config, LemonSqueezy client, subscriptions |
| Pricing | 3 | Config, user status, variant ID lookup |
| Components | 2 | Low-credit banner, usage counter |
| Other | 21 | API 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)
})
})
The
server.use() pattern lets you override any MSW handler for a single test without modifying shared handlers. The override is automatically cleaned up by server.resetHandlers() in afterEach. See API Mocking for detailed patterns.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)
})
})
When testing environment variables or feature flags, use
vi.resetModules() in afterEach to ensure each test gets a fresh module instance. Without this, cached module state from a previous test can cause false positives.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 File | Exports |
|---|---|
user.factory.ts | createMockFreeUser, createMockProUser, createMockEnterpriseUser, createMockTrialUser, createMockLockedUser |
subscription.factory.ts | createMockActiveSubscription, createMockTrialSubscription, createMockCancelledSubscription, createMockPausedSubscription, createMockExpiredSubscription |
file.factory.ts | createMockFile, createMockPdfFile, createMockImageFile, createMockLargeFile, createMockFileList |
ai-conversation.factory.ts | createMockConversation, createMockMessage |
ai-usage.factory.ts | createMockUsageRecord (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:
| Format | Output | Purpose |
|---|---|---|
text | Terminal | Quick overview during development |
json | coverage/coverage-final.json | CI integration and tooling |
html | coverage/index.html | Detailed per-file interactive report |
Excluded from Coverage
The following paths are excluded because they contain generated or third-party code:
node_modules/— Dependenciesapps/boilerplate/src/test/— Test utilities themselves*.config.ts— Build configuration**/*.d.ts— Type declarations.next/— Build outputapps/boilerplate/src/components/ui/**— shadcn/ui generated components
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/vitest.config.ts | Test environment, setup file, coverage, aliases |
apps/boilerplate/src/test/setup.ts | MSW lifecycle, jest-dom matchers, framework mocks |
apps/boilerplate/src/test/factories/ | Factory functions for generating test data |
apps/boilerplate/src/mocks/server.ts | Node.js MSW server used by all unit tests |
apps/boilerplate/src/mocks/handlers.ts | Default handlers for all API endpoints |