Skip to content

Latest commit

Β 

History

History
810 lines (646 loc) Β· 18.8 KB

File metadata and controls

810 lines (646 loc) Β· 18.8 KB

Day 5: Testing Framework Setup

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


🎯 Objectives

Primary Goals

  1. βœ… Unit tests for critical business logic (subscription, analytics)
  2. βœ… Integration tests for API routes (auth, Stripe webhook)
  3. βœ… Component tests for UI (modals, indicators)
  4. βœ… Test coverage reporting (target: 70%+)
  5. βœ… CI/CD integration (GitHub Actions)

Why Testing Matters

  • πŸ›‘οΈ 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

πŸ—οΈ Testing Stack

Core Dependencies

  • 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

Why This Stack?

  • βœ… 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

πŸ“¦ Installation (15 minutes)

# 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/jest

βš™οΈ Configuration

1. Jest Configuration (jest.config.js)

const 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)

2. Jest Setup (jest.setup.js)

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(),
}

3. Update package.json

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

4. TypeScript Configuration (tsconfig.json)

{
  "compilerOptions": {
    "types": ["jest", "@testing-library/jest-dom"]
  }
}

πŸ§ͺ Test Suites

Suite 1: Subscription Logic (lib/__tests__/subscription.test.ts)

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')
    })
  })
})

Suite 2: Analytics (lib/__tests__/analytics.test.ts)

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',
      })
    })
  })
})

Suite 3: Authentication API (app/api/auth/__tests__/login.test.ts)

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')
  })
})

Suite 4: UpgradeModal Component (components/__tests__/UpgradeModal.test.tsx)

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',
      })
    )
  })
})

Suite 5: UsageIndicator Component (components/__tests__/UsageIndicator.test.tsx)

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()
  })
})

πŸš€ Running Tests

# 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

πŸ“Š Coverage Goals

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

πŸ”„ CI/CD Integration (GitHub Actions)

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

🎯 Test Pyramid

        /\
       /  \  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).


🧩 Mocking Strategies

API Mocking with MSW

// 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 }))
  }),
]

Environment Mocking

// 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
})

πŸ“ Best Practices

1. Test User Behavior, Not Implementation

// ❌ Bad: Testing internal state
expect(component.state.isOpen).toBe(true)

// βœ… Good: Testing visible outcome
expect(screen.getByRole('dialog')).toBeVisible()

2. Use Data-testid Sparingly

// ❌ Bad: Relying on test IDs
screen.getByTestId('submit-button')

// βœ… Good: Use semantic queries
screen.getByRole('button', { name: /submit/i })

3. Arrange, Act, Assert Pattern

it('increments usage when message sent', () => {
  // Arrange
  const initialUsage = { count: 5 }

  // Act
  incrementUsage()

  // Assert
  expect(getUsage().count).toBe(6)
})

4. Isolate Tests

// Each test should be independent
beforeEach(() => {
  // Reset mocks
  jest.clearAllMocks()
  // Reset storage
  localStorage.clear()
})

πŸ”§ Debugging Tests

# 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"
}

βœ… Acceptance Criteria

  • 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

πŸ“š Next Steps After Testing

  1. Day 4 Part 2: Complete database integration (when ready)
  2. Week 2: E2E tests with Playwright (critical user flows)
  3. Week 3: Performance testing (load testing API)
  4. 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)