Testing

Testing strategies including unit tests, component tests, integration tests, and E2E testing

Testing

Comprehensive testing strategies to ensure code quality and reliability across unit, component, integration, and end-to-end testing.

Testing Setup

Jest Configuration

// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/$1',
  },
  collectCoverageFrom: [
    '**/*.{js,jsx,ts,tsx}',
    '!**/*.d.ts',
    '!**/node_modules/**',
    '!**/.next/**',
  ],
}

module.exports = createJestConfig(customJestConfig)
// jest.setup.js
import '@testing-library/jest-dom'

// Mock Next.js router
jest.mock('next/navigation', () => ({
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    back: jest.fn(),
  }),
  useSearchParams: () => new URLSearchParams(),
  usePathname: () => '/',
}))

Unit Testing

Utility Functions

// __tests__/utils.test.ts
import { formatCurrency, formatNumber, calculateTax } from '@/lib/utils'

describe('Utility Functions', () => {
  describe('formatCurrency', () => {
    it('formats currency correctly', () => {
      expect(formatCurrency(1234.56)).toBe('$1,234.56')
      expect(formatCurrency(0)).toBe('$0.00')
      expect(formatCurrency(-50)).toBe('-$50.00')
    })

    it('handles edge cases', () => {
      expect(formatCurrency(null)).toBe('$0.00')
      expect(formatCurrency(undefined)).toBe('$0.00')
    })
  })

  describe('formatNumber', () => {
    it('formats numbers with commas', () => {
      expect(formatNumber(1234)).toBe('1,234')
      expect(formatNumber(1234567)).toBe('1,234,567')
      expect(formatNumber(0)).toBe('0')
    })
  })

  describe('calculateTax', () => {
    it('calculates tax correctly', () => {
      expect(calculateTax(100, 0.08)).toBe(8)
      expect(calculateTax(0, 0.08)).toBe(0)
    })
  })
})

Service Layer Testing

// __tests__/services/inventory.test.ts
import { getInventoryStats } from '@/lib/services/inventory'
import { createClient } from '@/lib/supabase/server'

// Mock Supabase client
jest.mock('@/lib/supabase/server')
const mockCreateClient = createClient as jest.MockedFunction<typeof createClient>

describe('Inventory Service', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('calculates inventory stats correctly', async () => {
    const mockSupabase = {
      from: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      eq: jest.fn().mockResolvedValue({
        data: [
          {
            id: '1',
            cost_price: 10,
            inventory: [{ quantity_on_hand: 100, reorder_point: 20 }]
          },
          {
            id: '2',
            cost_price: 5,
            inventory: [{ quantity_on_hand: 5, reorder_point: 10 }]
          }
        ]
      })
    }

    mockCreateClient.mockResolvedValue(mockSupabase as any)

    const stats = await getInventoryStats()

    expect(stats.totalProducts).toBe(2)
    expect(stats.totalValue).toBe(1025) // (100 * 10) + (5 * 5)
    expect(stats.lowStockItems).toBe(1)
    expect(stats.outOfStockItems).toBe(0)
  })
})

Component Testing

Basic Component Testing

// __tests__/components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import { ProductCard } from '@/components/products/product-card'

const mockProduct = {
  id: '1',
  name: 'Test Product',
  sku: 'TEST-001',
  price: 29.99,
  stock: 100,
  image_url: '/test-image.jpg'
}

describe('ProductCard', () => {
  it('renders product information', () => {
    render(<ProductCard product={mockProduct} />)
    
    expect(screen.getByText('Test Product')).toBeInTheDocument()
    expect(screen.getByText('TEST-001')).toBeInTheDocument()
    expect(screen.getByText('$29.99')).toBeInTheDocument()
  })

  it('shows low stock warning', () => {
    const lowStockProduct = { ...mockProduct, stock: 5 }
    render(<ProductCard product={lowStockProduct} />)
    
    expect(screen.getByText(/low stock/i)).toBeInTheDocument()
  })

  it('shows out of stock message', () => {
    const outOfStockProduct = { ...mockProduct, stock: 0 }
    render(<ProductCard product={outOfStockProduct} />)
    
    expect(screen.getByText(/out of stock/i)).toBeInTheDocument()
  })
})

Interactive Component Testing

// __tests__/components/ProductForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ProductForm } from '@/components/products/product-form'

describe('ProductForm', () => {
  const mockOnSubmit = jest.fn()

  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('submits form with correct data', async () => {
    const user = userEvent.setup()
    
    render(<ProductForm onSubmit={mockOnSubmit} />)

    await user.type(screen.getByLabelText(/name/i), 'New Product')
    await user.type(screen.getByLabelText(/sku/i), 'NEW-001')
    await user.type(screen.getByLabelText(/price/i), '49.99')

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

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        name: 'New Product',
        sku: 'NEW-001',
        price: 49.99,
      })
    })
  })

  it('shows validation errors', async () => {
    const user = userEvent.setup()
    
    render(<ProductForm onSubmit={mockOnSubmit} />)

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

    expect(screen.getByText(/name is required/i)).toBeInTheDocument()
    expect(screen.getByText(/sku is required/i)).toBeInTheDocument()
    expect(mockOnSubmit).not.toHaveBeenCalled()
  })
})

Integration Testing

API Route Testing

// __tests__/api/products.test.ts
import { createMocks } from 'node-mocks-http'
import handler from '@/app/api/products/route'

// Mock database
jest.mock('@/lib/supabase/server')

describe('/api/products', () => {
  it('returns products list', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    })

    await handler(req, res)

    expect(res._getStatusCode()).toBe(200)
    
    const data = JSON.parse(res._getData())
    expect(data).toHaveProperty('products')
    expect(Array.isArray(data.products)).toBe(true)
  })

  it('creates new product', async () => {
    const { req, res } = createMocks({
      method: 'POST',
      body: {
        name: 'Test Product',
        sku: 'TEST-001',
        price: 29.99,
      },
    })

    await handler(req, res)

    expect(res._getStatusCode()).toBe(201)
    
    const data = JSON.parse(res._getData())
    expect(data.product).toMatchObject({
      name: 'Test Product',
      sku: 'TEST-001',
      price: 29.99,
    })
  })

  it('validates required fields', async () => {
    const { req, res } = createMocks({
      method: 'POST',
      body: {
        name: '', // Invalid empty name
      },
    })

    await handler(req, res)

    expect(res._getStatusCode()).toBe(400)
    expect(JSON.parse(res._getData())).toHaveProperty('error')
  })
})

Database Integration Testing

// __tests__/integration/database.test.ts
import { createClient } from '@supabase/supabase-js'
import { getProducts, createProduct } from '@/lib/services/products'

// Use test database
const supabase = createClient(
  process.env.TEST_SUPABASE_URL!,
  process.env.TEST_SUPABASE_KEY!
)

describe('Database Integration', () => {
  beforeEach(async () => {
    // Clean up test data
    await supabase.from('products').delete().neq('id', '')
  })

  it('creates and retrieves products', async () => {
    const productData = {
      name: 'Integration Test Product',
      sku: 'INT-001',
      price: 19.99,
    }

    const created = await createProduct(productData)
    expect(created).toMatchObject(productData)

    const products = await getProducts()
    expect(products).toHaveLength(1)
    expect(products[0]).toMatchObject(productData)
  })
})

End-to-End Testing

Playwright Setup

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    port: 3000,
  },
})

E2E Test Examples

// e2e/inventory.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Inventory Management', () => {
  test.beforeEach(async ({ page }) => {
    // Login
    await page.goto('/auth/login')
    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'password')
    await page.click('[type="submit"]')
    await expect(page).toHaveURL('/dashboard')
  })

  test('complete inventory workflow', async ({ page }) => {
    // Navigate to inventory
    await page.click('text=Inventory')
    await expect(page).toHaveURL('/inventory')

    // Add new product
    await page.click('text=Add Product')
    await page.fill('[name="name"]', 'E2E Test Product')
    await page.fill('[name="sku"]', 'E2E-001')
    await page.fill('[name="price"]', '29.99')
    await page.click('[type="submit"]')

    // Verify product appears in list
    await expect(page.locator('text=E2E Test Product')).toBeVisible()

    // Edit product
    await page.locator('[data-testid="product-E2E-001"]').click()
    await page.click('[data-testid="edit-button"]')
    await page.fill('[name="name"]', 'Updated E2E Product')
    await page.click('[type="submit"]')

    // Verify update
    await expect(page.locator('text=Updated E2E Product')).toBeVisible()

    // Delete product
    await page.click('[data-testid="delete-button"]')
    await page.click('[data-testid="confirm-delete"]')

    // Verify deletion
    await expect(page.locator('text=Updated E2E Product')).toBeHidden()
  })

  test('search and filter functionality', async ({ page }) => {
    await page.goto('/inventory')

    // Test search
    await page.fill('[placeholder="Search products..."]', 'laptop')
    await expect(page.locator('[data-testid="product-card"]')).toHaveCount(2)

    // Test category filter
    await page.selectOption('[name="category"]', 'electronics')
    await expect(page.locator('[data-testid="product-card"]')).toHaveCount(1)

    // Clear filters
    await page.click('[data-testid="clear-filters"]')
    await expect(page.locator('[data-testid="product-card"]')).toHaveCount(10)
  })
})

Testing Best Practices

Test Organization

  • Group related tests in describe blocks
  • Use descriptive test names
  • Keep tests focused and independent
  • Use beforeEach for common setup

Mocking Strategies

// Mock external dependencies
jest.mock('next/navigation')
jest.mock('@/lib/supabase/server')

// Mock only what you need
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
  useRouter: () => ({ push: mockPush }),
}))

Test Data Management

// __tests__/fixtures/products.ts
export const mockProducts = [
  {
    id: '1',
    name: 'Laptop',
    sku: 'LAP-001',
    price: 999.99,
    stock: 50,
  },
  {
    id: '2',
    name: 'Mouse',
    sku: 'MOU-001',
    price: 29.99,
    stock: 100,
  },
]

export const createMockProduct = (overrides = {}) => ({
  id: '1',
  name: 'Test Product',
  sku: 'TEST-001',
  price: 19.99,
  stock: 10,
  ...overrides,
})

Coverage Goals

  • Aim for 80%+ code coverage
  • Focus on critical business logic
  • Test edge cases and error scenarios
  • Use coverage reports to identify gaps

Continuous Integration

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - run: npm ci
      - run: npm run test:ci
      - run: npm run test:e2e
      
      - uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info