Date: October 18, 2025
Goal: Implement comprehensive testing infrastructure with Jest and React Testing Library
Estimated Time: 6 hours
Audit Impact: Testing 1/10 β 6/10
- β Unit tests for critical business logic (subscription, analytics)
- β Integration tests for API routes (auth, Stripe webhook)
- β Component tests for UI (modals, indicators)
- β Test coverage reporting (target: 70%+)
- β CI/CD integration (GitHub Actions)
- π‘οΈ Prevent regressions: Catch bugs before production
- π Faster development: Confident refactoring
- π Documentation: Tests show how code should work
- π° Revenue protection: Catch subscription/payment bugs
- π Security: Validate auth and rate limiting
- Jest: Test runner and assertion library
- React Testing Library: Component testing
- @testing-library/jest-dom: Custom matchers
- @testing-library/user-event: User interaction simulation
- MSW (Mock Service Worker): API mocking
- jest-environment-jsdom: Browser environment
- β Industry standard (most popular React testing setup)
- β Recommended by Next.js
- β Testing Library philosophy: test user behavior, not implementation
- β MSW for realistic API mocking
- β Excellent TypeScript support
# Core testing dependencies
pnpm add -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
# Next.js specific setup
pnpm add -D @testing-library/react-hooks
# API mocking
pnpm add -D msw
# Test coverage
pnpm add -D @jest/globals
# Types
pnpm add -D @types/jestconst nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'lib/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
'!**/coverage/**',
'!**/jest.config.js',
],
coverageThreshold: {
global: {
branches: 50,
functions: 50,
lines: 50,
statements: 50,
},
},
testMatch: [
'**/__tests__/**/*.{js,jsx,ts,tsx}',
'**/*.{spec,test}.{js,jsx,ts,tsx}',
],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)import '@testing-library/jest-dom'
// Mock environment variables
process.env.OPENAI_API_KEY = 'test-key'
process.env.AUTH_SECRET = 'test-secret'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}
},
usePathname() {
return '/'
},
}))
// Mock PostHog analytics
jest.mock('posthog-js', () => ({
init: jest.fn(),
capture: jest.fn(),
identify: jest.fn(),
reset: jest.fn(),
isFeatureEnabled: jest.fn(),
}))
// Mock Sentry
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
setUser: jest.fn(),
init: jest.fn(),
}))
// Suppress console errors in tests (optional)
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
}{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}{
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"]
}
}Coverage: Tier limits, access control, usage tracking
import {
canAccessDeck,
canMakeRequest,
getInteractionLimit,
getTierFromPriceId,
} from '../subscription'
describe('Subscription System', () => {
describe('canAccessDeck', () => {
it('allows free tier to access alpha deck', () => {
expect(canAccessDeck({ tier: 'free' }, 'alpha')).toBe(true)
})
it('blocks free tier from defense deck', () => {
expect(canAccessDeck({ tier: 'free' }, 'defense')).toBe(false)
})
it('allows pro tier to access defense deck', () => {
expect(canAccessDeck({ tier: 'pro' }, 'defense')).toBe(true)
})
it('blocks pro tier from medical deck', () => {
expect(canAccessDeck({ tier: 'pro' }, 'medical')).toBe(false)
})
it('allows enterprise tier to access all decks', () => {
expect(canAccessDeck({ tier: 'enterprise' }, 'alpha')).toBe(true)
expect(canAccessDeck({ tier: 'enterprise' }, 'defense')).toBe(true)
expect(canAccessDeck({ tier: 'enterprise' }, 'medical')).toBe(true)
})
})
describe('getInteractionLimit', () => {
it('returns 10 for free tier', () => {
expect(getInteractionLimit('free')).toBe(10)
})
it('returns 100 for basic tier', () => {
expect(getInteractionLimit('basic')).toBe(100)
})
it('returns 500 for pro tier', () => {
expect(getInteractionLimit('pro')).toBe(500)
})
it('returns Infinity for enterprise tier', () => {
expect(getInteractionLimit('enterprise')).toBe(Infinity)
})
})
describe('canMakeRequest', () => {
it('allows request when under limit', () => {
const subscription = { tier: 'basic' as const }
const usage = { count: 50 }
expect(canMakeRequest(subscription, usage)).toBe(true)
})
it('blocks request when at limit', () => {
const subscription = { tier: 'basic' as const }
const usage = { count: 100 }
expect(canMakeRequest(subscription, usage)).toBe(false)
})
it('blocks request when over limit', () => {
const subscription = { tier: 'free' as const }
const usage = { count: 15 }
expect(canMakeRequest(subscription, usage)).toBe(false)
})
it('always allows enterprise tier', () => {
const subscription = { tier: 'enterprise' as const }
const usage = { count: 9999999 }
expect(canMakeRequest(subscription, usage)).toBe(true)
})
})
describe('getTierFromPriceId', () => {
it('returns correct tier for standard price', () => {
const priceId = process.env.NEXT_PUBLIC_STRIPE_PRICE_STANDARD || 'price_standard'
expect(getTierFromPriceId(priceId)).toBe('basic')
})
it('returns free tier for unknown price', () => {
expect(getTierFromPriceId('unknown_price')).toBe('free')
})
})
})import { trackEvent, identifyUser, ANALYTICS_EVENTS } from '../analytics'
import posthog from 'posthog-js'
jest.mock('posthog-js')
describe('Analytics', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('trackEvent', () => {
it('calls posthog.capture with event name and properties', () => {
trackEvent(ANALYTICS_EVENTS.AI_MESSAGE_SENT, {
deck: 'alpha',
tier: 'pro',
})
expect(posthog.capture).toHaveBeenCalledWith(
'ai_message_sent',
{ deck: 'alpha', tier: 'pro' }
)
})
it('handles missing properties', () => {
trackEvent(ANALYTICS_EVENTS.LOGIN_SUCCESS)
expect(posthog.capture).toHaveBeenCalledWith('login_success', undefined)
})
})
describe('identifyUser', () => {
it('calls posthog.identify with user ID and traits', () => {
identifyUser('user123', { email: 'test@example.com', tier: 'pro' })
expect(posthog.identify).toHaveBeenCalledWith('user123', {
email: 'test@example.com',
tier: 'pro',
})
})
})
})import { POST } from '../login/route'
import { NextRequest } from 'next/server'
describe('/api/auth/login', () => {
it('returns 401 for invalid password', async () => {
const request = new NextRequest('http://localhost:3000/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password: 'wrong' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.ok).toBe(false)
expect(data.message).toBe('Invalid credentials.')
})
it('returns 200 and sets cookie for valid password', async () => {
process.env.AUTH_PASSWORDS = 'test-password'
const request = new NextRequest('http://localhost:3000/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password: 'test-password' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.ok).toBe(true)
expect(response.cookies.get('dgpt_session')).toBeDefined()
})
it('returns 500 if auth not configured', async () => {
delete process.env.AUTH_SECRET
delete process.env.AUTH_PASSWORDS
const request = new NextRequest('http://localhost:3000/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password: 'anything' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.message).toContain('not configured')
})
})import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import UpgradeModal from '../UpgradeModal'
import { useRouter } from 'next/navigation'
jest.mock('next/navigation')
describe('UpgradeModal', () => {
const mockRouter = {
push: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
;(useRouter as jest.Mock).mockReturnValue(mockRouter)
})
it('renders with limit reason', () => {
render(
<UpgradeModal
reason="limit"
currentTier="free"
onClose={jest.fn()}
interactionsUsed={10}
interactionLimit={10}
/>
)
expect(screen.getByText(/Free Tier Limit Reached/i)).toBeInTheDocument()
expect(screen.getByText(/10\/10/i)).toBeInTheDocument()
})
it('renders with deck reason', () => {
render(
<UpgradeModal
reason="deck"
currentTier="free"
onClose={jest.fn()}
targetDeck="defense"
/>
)
expect(screen.getByText(/Deck Access Restricted/i)).toBeInTheDocument()
expect(screen.getByText(/Defense Deck/i)).toBeInTheDocument()
})
it('calls onClose when close button clicked', () => {
const onClose = jest.fn()
render(
<UpgradeModal
reason="limit"
currentTier="free"
onClose={onClose}
/>
)
fireEvent.click(screen.getByLabelText('Close'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('navigates to pricing when upgrade clicked', async () => {
render(
<UpgradeModal
reason="limit"
currentTier="free"
onClose={jest.fn()}
/>
)
const upgradeButton = screen.getByText(/Upgrade to Basic/i)
fireEvent.click(upgradeButton)
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/pricing')
})
})
it('tracks analytics event when shown', () => {
const { trackEvent } = require('@/lib/analytics')
render(
<UpgradeModal
reason="limit"
currentTier="pro"
onClose={jest.fn()}
interactionsUsed={500}
interactionLimit={500}
/>
)
expect(trackEvent).toHaveBeenCalledWith(
expect.stringContaining('upgrade_modal_shown'),
expect.objectContaining({
reason: 'limit',
current_tier: 'pro',
})
)
})
})import { render, screen } from '@testing-library/react'
import UsageIndicator from '../UsageIndicator'
describe('UsageIndicator', () => {
it('shows green for low usage', () => {
const { container } = render(
<UsageIndicator
used={20}
limit={100}
tier="basic"
/>
)
expect(screen.getByText('20/100')).toBeInTheDocument()
expect(container.querySelector('.bg-green-500')).toBeInTheDocument()
})
it('shows yellow for medium usage', () => {
const { container } = render(
<UsageIndicator
used={75}
limit={100}
tier="basic"
/>
)
expect(screen.getByText('75/100')).toBeInTheDocument()
expect(container.querySelector('.bg-yellow-500')).toBeInTheDocument()
})
it('shows red for high usage', () => {
const { container } = render(
<UsageIndicator
used={95}
limit={100}
tier="basic"
/>
)
expect(screen.getByText('95/100')).toBeInTheDocument()
expect(container.querySelector('.bg-red-500')).toBeInTheDocument()
})
it('shows unlimited for enterprise', () => {
render(
<UsageIndicator
used={9999}
limit={Infinity}
tier="enterprise"
/>
)
expect(screen.getByText(/Unlimited/i)).toBeInTheDocument()
})
})# Run all tests
pnpm test
# Watch mode (re-run on file changes)
pnpm test:watch
# Coverage report
pnpm test:coverage
# CI mode (GitHub Actions)
pnpm test:ci| Category | Target | Priority |
|---|---|---|
| Critical Paths | 90%+ | High |
| - Subscription logic | 95% | Critical |
| - Payment processing | 90% | Critical |
| - Authentication | 90% | Critical |
| Business Logic | 70%+ | Medium |
| - Usage tracking | 80% | High |
| - Deck access control | 75% | High |
| - Analytics tracking | 70% | Medium |
| UI Components | 60%+ | Medium |
| - Modals | 70% | Medium |
| - Indicators | 60% | Low |
| - Layout | 50% | Low |
| API Routes | 80%+ | High |
| - Auth endpoints | 90% | Critical |
| - Stripe webhooks | 85% | Critical |
| - OpenAI proxy | 70% | Medium |
Create .github/workflows/test.yml:
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run linter
run: pnpm lint
- name: Run type check
run: pnpm tsc --noEmit
- name: Run tests
run: pnpm test:ci
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
uses: codecov/codecov-action@v3 /\
/ \ E2E Tests (5%)
/ \ - Critical user flows
/______\
/ \ Integration Tests (25%)
/ \ - API routes
/ \ - Component interactions
/______________\
/ \ Unit Tests (70%)
- Business logic
- Pure functions
- Utilities
Philosophy: Majority unit tests (fast, isolated), some integration tests (API routes), minimal E2E (critical flows only).
// lib/__tests__/mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
rest.post('/api/openai', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ response: 'Mocked AI response' })
)
}),
rest.post('/api/stripe/webhook', (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ received: true }))
}),
]// In test file
beforeEach(() => {
process.env.OPENAI_API_KEY = 'test-key'
process.env.STRIPE_SECRET_KEY = 'sk_test_mock'
})
afterEach(() => {
delete process.env.OPENAI_API_KEY
delete process.env.STRIPE_SECRET_KEY
})// β Bad: Testing internal state
expect(component.state.isOpen).toBe(true)
// β
Good: Testing visible outcome
expect(screen.getByRole('dialog')).toBeVisible()// β Bad: Relying on test IDs
screen.getByTestId('submit-button')
// β
Good: Use semantic queries
screen.getByRole('button', { name: /submit/i })it('increments usage when message sent', () => {
// Arrange
const initialUsage = { count: 5 }
// Act
incrementUsage()
// Assert
expect(getUsage().count).toBe(6)
})// Each test should be independent
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Reset storage
localStorage.clear()
})# Run specific test file
pnpm test subscription.test.ts
# Run tests matching pattern
pnpm test --testNamePattern="canAccessDeck"
# Debug in VS Code
# Add to launch.json:
{
"type": "node",
"request": "launch",
"name": "Jest Debug",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--no-cache"],
"console": "integratedTerminal"
}- Jest and React Testing Library installed
- Jest configuration complete
- Test script in package.json
- Subscription tests pass (10+ tests)
- Analytics tests pass (5+ tests)
- Auth API tests pass (3+ tests)
- Component tests pass (10+ tests)
- Coverage report generated
- Coverage > 50% on critical paths
- GitHub Actions workflow created
- All tests pass in CI
- Day 4 Part 2: Complete database integration (when ready)
- Week 2: E2E tests with Playwright (critical user flows)
- Week 3: Performance testing (load testing API)
- Week 4: Security testing (penetration testing, OWASP)
Estimated Breakdown:
- Installation & config: 30 min
- Subscription tests: 1 hour
- Analytics tests: 30 min
- API route tests: 1.5 hours
- Component tests: 2 hours
- CI/CD setup: 30 min
- Documentation: 30 min
Total: 6 hours (can split into 2 sessions of 3 hours)