Currently viewing:

Home

Portfolio • 2025

Back to Blog
React Testing

Testing Strategies for React Applications: Jest & Testing Library

Master React testing with Jest and Testing Library. Learn comprehensive testing strategies, TDD patterns, and testing best practices for production banking applications.

November 30, 202016 min read

Complete React Testing Guide

  • • Jest and Testing Library setup and configuration
  • • Unit testing React components and hooks
  • • Integration testing with API mocking
  • • Test-driven development (TDD) patterns
  • • Banking application testing scenarios
  • • CI/CD testing pipeline integration

Testing Setup and Configuration

// package.json testing dependencies
{
  "devDependencies": {
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.1.0",
    "@testing-library/user-event": "^14.5.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "@types/jest": "^29.5.0",
    "msw": "^2.0.0"
  },
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --watchAll=false"
  }
}

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
    '!src/main.tsx'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
    '<rootDir>/src/**/*.{test,spec}.{ts,tsx}'
  ]
};

// src/setupTests.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';

// Establish API mocking before all tests
beforeAll(() => server.listen());

// Reset any request handlers after each test
afterEach(() => server.resetHandlers());

// Clean up after all tests
afterAll(() => server.close());

// Mock IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
  observe: jest.fn(),
  unobserve: jest.fn(),
  disconnect: jest.fn(),
}));

// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

Component Testing Patterns

// components/AccountBalance.tsx
import React from 'react';
import { formatCurrency } from '@/utils/currency';

interface AccountBalanceProps {
  balance: number;
  currency: string;
  accountType: 'checking' | 'savings' | 'credit';
  isLoading?: boolean;
  onViewDetails?: () => void;
}

export const AccountBalance: React.FC<AccountBalanceProps> = ({
  balance,
  currency,
  accountType,
  isLoading = false,
  onViewDetails
}) => {
  if (isLoading) {
    return (
      <div className="account-balance loading" data-testid="account-balance-loading">
        <div className="skeleton" />
      </div>
    );
  }

  const isNegative = balance < 0;
  const balanceClass = `balance ${isNegative ? 'negative' : 'positive'}`;

  return (
    <div className="account-balance" data-testid="account-balance">
      <h3 className="account-type" data-testid="account-type">
        {accountType.charAt(0).toUpperCase() + accountType.slice(1)} Account
      </h3>
      <div className={balanceClass} data-testid="balance-amount">
        {formatCurrency(balance, currency)}
      </div>
      {isNegative && (
        <div className="warning" data-testid="negative-balance-warning">
          Account balance is negative
        </div>
      )}
      {onViewDetails && (
        <button 
          onClick={onViewDetails}
          data-testid="view-details-button"
          className="view-details-btn"
        >
          View Details
        </button>
      )}
    </div>
  );
};

// components/__tests__/AccountBalance.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { AccountBalance } from '../AccountBalance';

describe('AccountBalance Component', () => {
  const defaultProps = {
    balance: 1500.50,
    currency: 'USD',
    accountType: 'checking' as const
  };

  describe('Rendering', () => {
    it('renders account balance correctly', () => {
      render(<AccountBalance {...defaultProps} />);
      
      expect(screen.getByTestId('account-balance')).toBeInTheDocument();
      expect(screen.getByTestId('account-type')).toHaveTextContent('Checking Account');
      expect(screen.getByTestId('balance-amount')).toHaveTextContent('$1,500.50');
    });

    it('displays loading state when isLoading is true', () => {
      render(<AccountBalance {...defaultProps} isLoading={true} />);
      
      expect(screen.getByTestId('account-balance-loading')).toBeInTheDocument();
      expect(screen.queryByTestId('account-balance')).not.toBeInTheDocument();
    });

    it('renders different account types correctly', () => {
      const { rerender } = render(<AccountBalance {...defaultProps} accountType="savings" />);
      expect(screen.getByTestId('account-type')).toHaveTextContent('Savings Account');

      rerender(<AccountBalance {...defaultProps} accountType="credit" />);
      expect(screen.getByTestId('account-type')).toHaveTextContent('Credit Account');
    });
  });

  describe('Negative Balance Handling', () => {
    it('displays warning for negative balance', () => {
      render(<AccountBalance {...defaultProps} balance={-250.75} />);
      
      const balanceElement = screen.getByTestId('balance-amount');
      expect(balanceElement).toHaveClass('negative');
      expect(screen.getByTestId('negative-balance-warning')).toHaveTextContent(
        'Account balance is negative'
      );
    });

    it('does not display warning for positive balance', () => {
      render(<AccountBalance {...defaultProps} balance={100} />);
      
      const balanceElement = screen.getByTestId('balance-amount');
      expect(balanceElement).toHaveClass('positive');
      expect(screen.queryByTestId('negative-balance-warning')).not.toBeInTheDocument();
    });
  });

  describe('User Interactions', () => {
    it('calls onViewDetails when button is clicked', () => {
      const mockOnViewDetails = jest.fn();
      render(<AccountBalance {...defaultProps} onViewDetails={mockOnViewDetails} />);
      
      const viewDetailsButton = screen.getByTestId('view-details-button');
      fireEvent.click(viewDetailsButton);
      
      expect(mockOnViewDetails).toHaveBeenCalledTimes(1);
    });

    it('does not render view details button when onViewDetails is not provided', () => {
      render(<AccountBalance {...defaultProps} />);
      
      expect(screen.queryByTestId('view-details-button')).not.toBeInTheDocument();
    });
  });

  describe('Currency Formatting', () => {
    it('formats different currencies correctly', () => {
      const { rerender } = render(
        <AccountBalance {...defaultProps} balance={1000} currency="EUR" />
      );
      expect(screen.getByTestId('balance-amount')).toHaveTextContent('€1,000.00');

      rerender(<AccountBalance {...defaultProps} balance={1000} currency="GBP" />);
      expect(screen.getByTestId('balance-amount')).toHaveTextContent('£1,000.00');
    });
  });
});

Hook Testing Strategies

// hooks/useAccountData.ts
import { useState, useEffect } from 'react';
import { accountService } from '@/services/accountService';

interface Account {
  id: string;
  balance: number;
  type: string;
  currency: string;
}

interface UseAccountDataReturn {
  accounts: Account[];
  loading: boolean;
  error: string | null;
  refetch: () => Promise<void>;
}

export const useAccountData = (userId: string): UseAccountDataReturn => {
  const [accounts, setAccounts] = useState<Account[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchAccounts = async () => {
    try {
      setLoading(true);
      setError(null);
      const data = await accountService.getAccounts(userId);
      setAccounts(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch accounts');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    if (userId) {
      fetchAccounts();
    }
  }, [userId]);

  return {
    accounts,
    loading,
    error,
    refetch: fetchAccounts
  };
};

// hooks/__tests__/useAccountData.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useAccountData } from '../useAccountData';
import { accountService } from '@/services/accountService';

// Mock the account service
jest.mock('@/services/accountService');
const mockAccountService = accountService as jest.Mocked<typeof accountService>;

describe('useAccountData Hook', () => {
  const mockAccounts = [
    { id: '1', balance: 1000, type: 'checking', currency: 'USD' },
    { id: '2', balance: 5000, type: 'savings', currency: 'USD' }
  ];

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

  describe('Initial State', () => {
    it('returns initial loading state', () => {
      mockAccountService.getAccounts.mockResolvedValue(mockAccounts);
      
      const { result } = renderHook(() => useAccountData('user-123'));
      
      expect(result.current.loading).toBe(true);
      expect(result.current.accounts).toEqual([]);
      expect(result.current.error).toBe(null);
    });
  });

  describe('Successful Data Fetching', () => {
    it('fetches and returns account data', async () => {
      mockAccountService.getAccounts.mockResolvedValue(mockAccounts);
      
      const { result } = renderHook(() => useAccountData('user-123'));
      
      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });
      
      expect(result.current.accounts).toEqual(mockAccounts);
      expect(result.current.error).toBe(null);
      expect(mockAccountService.getAccounts).toHaveBeenCalledWith('user-123');
    });

    it('refetches data when refetch is called', async () => {
      mockAccountService.getAccounts.mockResolvedValue(mockAccounts);
      
      const { result } = renderHook(() => useAccountData('user-123'));
      
      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });
      
      // Clear the mock and set up new data
      mockAccountService.getAccounts.mockClear();
      const updatedAccounts = [
        { id: '1', balance: 1200, type: 'checking', currency: 'USD' }
      ];
      mockAccountService.getAccounts.mockResolvedValue(updatedAccounts);
      
      // Call refetch
      await result.current.refetch();
      
      expect(mockAccountService.getAccounts).toHaveBeenCalledTimes(1);
      expect(result.current.accounts).toEqual(updatedAccounts);
    });
  });

  describe('Error Handling', () => {
    it('handles service errors correctly', async () => {
      const errorMessage = 'Network error';
      mockAccountService.getAccounts.mockRejectedValue(new Error(errorMessage));
      
      const { result } = renderHook(() => useAccountData('user-123'));
      
      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });
      
      expect(result.current.error).toBe(errorMessage);
      expect(result.current.accounts).toEqual([]);
    });

    it('handles non-Error objects', async () => {
      mockAccountService.getAccounts.mockRejectedValue('String error');
      
      const { result } = renderHook(() => useAccountData('user-123'));
      
      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });
      
      expect(result.current.error).toBe('Failed to fetch accounts');
    });
  });

  describe('User ID Changes', () => {
    it('refetches data when userId changes', async () => {
      mockAccountService.getAccounts.mockResolvedValue(mockAccounts);
      
      const { result, rerender } = renderHook(
        ({ userId }) => useAccountData(userId),
        { initialProps: { userId: 'user-123' } }
      );
      
      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });
      
      expect(mockAccountService.getAccounts).toHaveBeenCalledWith('user-123');
      
      // Change userId
      rerender({ userId: 'user-456' });
      
      await waitFor(() => {
        expect(mockAccountService.getAccounts).toHaveBeenCalledWith('user-456');
      });
      
      expect(mockAccountService.getAccounts).toHaveBeenCalledTimes(2);
    });

    it('does not fetch when userId is empty', () => {
      const { result } = renderHook(() => useAccountData(''));
      
      expect(result.current.loading).toBe(true);
      expect(mockAccountService.getAccounts).not.toHaveBeenCalled();
    });
  });
});

Integration Testing with MSW

// src/mocks/handlers.ts
import { rest } from 'msw';

const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';

export const handlers = [
  // Account endpoints
  rest.get(`${API_BASE_URL}/api/accounts/:userId`, (req, res, ctx) => {
    const { userId } = req.params;
    
    if (userId === 'user-error') {
      return res(
        ctx.status(500),
        ctx.json({ error: 'Internal server error' })
      );
    }
    
    return res(
      ctx.status(200),
      ctx.json([
        {
          id: 'acc-1',
          userId,
          balance: 1500.50,
          type: 'checking',
          currency: 'USD'
        },
        {
          id: 'acc-2',
          userId,
          balance: 5000.00,
          type: 'savings',
          currency: 'USD'
        }
      ])
    );
  }),

  // Transaction endpoints
  rest.get(`${API_BASE_URL}/api/transactions/:accountId`, (req, res, ctx) => {
    const { accountId } = req.params;
    const page = req.url.searchParams.get('page') || '1';
    const limit = req.url.searchParams.get('limit') || '10';
    
    return res(
      ctx.status(200),
      ctx.json({
        transactions: [
          {
            id: 'txn-1',
            accountId,
            amount: -25.50,
            description: 'Coffee Shop',
            category: 'Food & Dining',
            date: '2025-08-20T10:30:00Z',
            status: 'completed'
          },
          {
            id: 'txn-2',
            accountId,
            amount: 1000.00,
            description: 'Salary Deposit',
            category: 'Income',
            date: '2025-08-19T09:00:00Z',
            status: 'completed'
          }
        ],
        pagination: {
          page: parseInt(page),
          limit: parseInt(limit),
          total: 25,
          hasMore: true
        }
      })
    );
  }),

  // Transfer endpoint
  rest.post(`${API_BASE_URL}/api/transfers`, async (req, res, ctx) => {
    const body = await req.json();
    
    if (body.amount > 10000) {
      return res(
        ctx.status(400),
        ctx.json({ error: 'Transfer amount exceeds daily limit' })
      );
    }
    
    return res(
      ctx.status(201),
      ctx.json({
        id: 'transfer-123',
        status: 'pending',
        ...body,
        createdAt: new Date().toISOString()
      })
    );
  })
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// components/__tests__/TransferForm.integration.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TransferForm } from '../TransferForm';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

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

describe('TransferForm Integration Tests', () => {
  const user = userEvent.setup();
  
  const defaultProps = {
    fromAccounts: [
      { id: 'acc-1', name: 'Checking', balance: 1500.50 },
      { id: 'acc-2', name: 'Savings', balance: 5000.00 }
    ]
  };

  describe('Successful Transfer Flow', () => {
    it('completes a transfer successfully', async () => {
      const onSuccess = jest.fn();
      
      render(
        <TransferForm {...defaultProps} onSuccess={onSuccess} />,
        { wrapper: createWrapper() }
      );
      
      // Fill out the form
      const fromAccountSelect = screen.getByLabelText(/from account/i);
      const toAccountInput = screen.getByLabelText(/to account/i);
      const amountInput = screen.getByLabelText(/amount/i);
      const descriptionInput = screen.getByLabelText(/description/i);
      
      await user.selectOptions(fromAccountSelect, 'acc-1');
      await user.type(toAccountInput, '1234567890');
      await user.type(amountInput, '500');
      await user.type(descriptionInput, 'Monthly savings transfer');
      
      // Submit the form
      const submitButton = screen.getByRole('button', { name: /transfer/i });
      await user.click(submitButton);
      
      // Wait for success
      await waitFor(() => {
        expect(onSuccess).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'transfer-123',
            status: 'pending'
          })
        );
      });
      
      // Check for success message
      expect(screen.getByText(/transfer initiated successfully/i)).toBeInTheDocument();
    });
  });

  describe('Error Handling', () => {
    it('displays error for excessive transfer amount', async () => {
      render(<TransferForm {...defaultProps} />, { wrapper: createWrapper() });
      
      const fromAccountSelect = screen.getByLabelText(/from account/i);
      const toAccountInput = screen.getByLabelText(/to account/i);
      const amountInput = screen.getByLabelText(/amount/i);
      
      await user.selectOptions(fromAccountSelect, 'acc-1');
      await user.type(toAccountInput, '1234567890');
      await user.type(amountInput, '15000');
      
      const submitButton = screen.getByRole('button', { name: /transfer/i });
      await user.click(submitButton);
      
      await waitFor(() => {
        expect(screen.getByText(/transfer amount exceeds daily limit/i)).toBeInTheDocument();
      });
    });

    it('validates insufficient balance', async () => {
      render(<TransferForm {...defaultProps} />, { wrapper: createWrapper() });
      
      const fromAccountSelect = screen.getByLabelText(/from account/i);
      const amountInput = screen.getByLabelText(/amount/i);
      
      await user.selectOptions(fromAccountSelect, 'acc-1'); // Balance: 1500.50
      await user.type(amountInput, '2000'); // More than balance
      
      // Blur to trigger validation
      await user.tab();
      
      expect(screen.getByText(/insufficient balance/i)).toBeInTheDocument();
    });
  });

  describe('Form Validation', () => {
    it('validates required fields', async () => {
      render(<TransferForm {...defaultProps} />, { wrapper: createWrapper() });
      
      const submitButton = screen.getByRole('button', { name: /transfer/i });
      await user.click(submitButton);
      
      expect(screen.getByText(/please select an account/i)).toBeInTheDocument();
      expect(screen.getByText(/account number is required/i)).toBeInTheDocument();
      expect(screen.getByText(/amount is required/i)).toBeInTheDocument();
    });

    it('validates account number format', async () => {
      render(<TransferForm {...defaultProps} />, { wrapper: createWrapper() });
      
      const toAccountInput = screen.getByLabelText(/to account/i);
      await user.type(toAccountInput, '123'); // Too short
      await user.tab();
      
      expect(screen.getByText(/account number must be 10 digits/i)).toBeInTheDocument();
    });
  });
});

Test-Driven Development (TDD)

// TDD Example: Building a Budget Tracker Component

// Step 1: Write the test first
// components/__tests__/BudgetTracker.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BudgetTracker } from '../BudgetTracker';

describe('BudgetTracker Component - TDD', () => {
  const mockBudget = {
    id: 'budget-1',
    name: 'Monthly Budget',
    totalAmount: 3000,
    categories: [
      { name: 'Food', allocated: 500, spent: 320 },
      { name: 'Transportation', allocated: 300, spent: 250 },
      { name: 'Entertainment', allocated: 200, spent: 180 }
    ]
  };

  describe('RED Phase - Tests that should fail initially', () => {
    it('displays budget overview with total and remaining amounts', () => {
      render(<BudgetTracker budget={mockBudget} />);
      
      expect(screen.getByTestId('budget-total')).toHaveTextContent('$3,000.00');
      expect(screen.getByTestId('budget-spent')).toHaveTextContent('$750.00');
      expect(screen.getByTestId('budget-remaining')).toHaveTextContent('$2,250.00');
    });

    it('displays each category with progress bars', () => {
      render(<BudgetTracker budget={mockBudget} />);
      
      mockBudget.categories.forEach(category => {
        expect(screen.getByText(category.name)).toBeInTheDocument();
        expect(screen.getByTestId(`category-${category.name.toLowerCase()}`)).toBeInTheDocument();
      });
    });

    it('calculates and displays percentage spent for each category', () => {
      render(<BudgetTracker budget={mockBudget} />);
      
      // Food: 320/500 = 64%
      expect(screen.getByTestId('category-food-percentage')).toHaveTextContent('64%');
      // Transportation: 250/300 = 83.3%
      expect(screen.getByTestId('category-transportation-percentage')).toHaveTextContent('83%');
    });

    it('highlights categories that exceed budget', () => {
      const overBudget = {
        ...mockBudget,
        categories: [
          { name: 'Food', allocated: 500, spent: 620 } // Over budget
        ]
      };
      
      render(<BudgetTracker budget={overBudget} />);
      
      expect(screen.getByTestId('category-food')).toHaveClass('over-budget');
      expect(screen.getByTestId('category-food-warning')).toBeInTheDocument();
    });

    it('allows adding new expense to a category', async () => {
      const user = userEvent.setup();
      const onAddExpense = jest.fn();
      
      render(<BudgetTracker budget={mockBudget} onAddExpense={onAddExpense} />);
      
      const addExpenseButton = screen.getByTestId('add-expense-food');
      await user.click(addExpenseButton);
      
      const amountInput = screen.getByLabelText(/expense amount/i);
      const descriptionInput = screen.getByLabelText(/description/i);
      
      await user.type(amountInput, '25.50');
      await user.type(descriptionInput, 'Grocery shopping');
      
      const submitButton = screen.getByRole('button', { name: /add expense/i });
      await user.click(submitButton);
      
      expect(onAddExpense).toHaveBeenCalledWith({
        category: 'Food',
        amount: 25.50,
        description: 'Grocery shopping'
      });
    });
  });
});

// Step 2: GREEN Phase - Implement minimal code to pass tests
// components/BudgetTracker.tsx
import React, { useState } from 'react';

interface BudgetCategory {
  name: string;
  allocated: number;
  spent: number;
}

interface Budget {
  id: string;
  name: string;
  totalAmount: number;
  categories: BudgetCategory[];
}

interface BudgetTrackerProps {
  budget: Budget;
  onAddExpense?: (expense: { category: string; amount: number; description: string }) => void;
}

export const BudgetTracker: React.FC<BudgetTrackerProps> = ({ budget, onAddExpense }) => {
  const [showExpenseForm, setShowExpenseForm] = useState<string | null>(null);
  const [expenseAmount, setExpenseAmount] = useState('');
  const [expenseDescription, setExpenseDescription] = useState('');

  const totalSpent = budget.categories.reduce((sum, cat) => sum + cat.spent, 0);
  const totalRemaining = budget.totalAmount - totalSpent;

  const formatCurrency = (amount: number) => 
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);

  const getPercentageSpent = (allocated: number, spent: number) => 
    Math.round((spent / allocated) * 100);

  const isOverBudget = (allocated: number, spent: number) => spent > allocated;

  const handleAddExpense = (category: string) => {
    if (onAddExpense && expenseAmount && expenseDescription) {
      onAddExpense({
        category,
        amount: parseFloat(expenseAmount),
        description: expenseDescription
      });
      setExpenseAmount('');
      setExpenseDescription('');
      setShowExpenseForm(null);
    }
  };

  return (
    <div className="budget-tracker">
      <div className="budget-overview">
        <h2>{budget.name}</h2>
        <div className="budget-summary">
          <div data-testid="budget-total">
            Total: {formatCurrency(budget.totalAmount)}
          </div>
          <div data-testid="budget-spent">
            Spent: {formatCurrency(totalSpent)}
          </div>
          <div data-testid="budget-remaining">
            Remaining: {formatCurrency(totalRemaining)}
          </div>
        </div>
      </div>

      <div className="categories">
        {budget.categories.map(category => {
          const percentage = getPercentageSpent(category.allocated, category.spent);
          const overBudget = isOverBudget(category.allocated, category.spent);

          return (
            <div
              key={category.name}
              data-testid={`category-${category.name.toLowerCase()}`}
              className={`category ${overBudget ? 'over-budget' : ''}`}
            >
              <div className="category-header">
                <h3>{category.name}</h3>
                <div data-testid={`category-${category.name.toLowerCase()}-percentage`}>
                  {percentage}%
                </div>
              </div>

              <div className="category-amounts">
                <span>Spent: {formatCurrency(category.spent)}</span>
                <span>Budget: {formatCurrency(category.allocated)}</span>
              </div>

              <div className="progress-bar">
                <div
                  className="progress-fill"
                  style={{ width: `${Math.min(percentage, 100)}%` }}
                />
              </div>

              {overBudget && (
                <div data-testid={`category-${category.name.toLowerCase()}-warning`} className="warning">
                  Over budget by {formatCurrency(category.spent - category.allocated)}
                </div>
              )}

              <button
                data-testid={`add-expense-${category.name.toLowerCase()}`}
                onClick={() => setShowExpenseForm(category.name)}
                className="add-expense-btn"
              >
                Add Expense
              </button>

              {showExpenseForm === category.name && (
                <div className="expense-form">
                  <input
                    type="number"
                    value={expenseAmount}
                    onChange={(e) => setExpenseAmount(e.target.value)}
                    placeholder="Amount"
                    aria-label="Expense amount"
                  />
                  <input
                    type="text"
                    value={expenseDescription}
                    onChange={(e) => setExpenseDescription(e.target.value)}
                    placeholder="Description"
                    aria-label="Description"
                  />
                  <button onClick={() => handleAddExpense(category.name)}>
                    Add Expense
                  </button>
                  <button onClick={() => setShowExpenseForm(null)}>
                    Cancel
                  </button>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
};

// Step 3: REFACTOR Phase - Improve code quality while keeping tests green
// Extract custom hooks
const useBudgetCalculations = (budget: Budget) => {
  return useMemo(() => {
    const totalSpent = budget.categories.reduce((sum, cat) => sum + cat.spent, 0);
    const totalRemaining = budget.totalAmount - totalSpent;
    
    return {
      totalSpent,
      totalRemaining,
      overallPercentage: Math.round((totalSpent / budget.totalAmount) * 100)
    };
  }, [budget]);
};

// Extract utility functions
export const budgetUtils = {
  formatCurrency: (amount: number, currency = 'USD') => 
    new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount),
    
  getPercentageSpent: (allocated: number, spent: number) => 
    Math.round((spent / allocated) * 100),
    
  isOverBudget: (allocated: number, spent: number) => spent > allocated,
    
  getCategoryStatus: (allocated: number, spent: number) => {
    const percentage = (spent / allocated) * 100;
    if (percentage >= 100) return 'over-budget';
    if (percentage >= 80) return 'warning';
    if (percentage >= 60) return 'caution';
    return 'normal';
  }
};

// Unit tests for utility functions
describe('budgetUtils', () => {
  describe('formatCurrency', () => {
    it('formats USD currency correctly', () => {
      expect(budgetUtils.formatCurrency(1234.56)).toBe('$1,234.56');
    });

    it('formats other currencies correctly', () => {
      expect(budgetUtils.formatCurrency(1000, 'EUR')).toBe('€1,000.00');
    });
  });

  describe('getCategoryStatus', () => {
    it('returns correct status for different spending levels', () => {
      expect(budgetUtils.getCategoryStatus(100, 50)).toBe('normal');
      expect(budgetUtils.getCategoryStatus(100, 70)).toBe('caution');
      expect(budgetUtils.getCategoryStatus(100, 85)).toBe('warning');
      expect(budgetUtils.getCategoryStatus(100, 110)).toBe('over-budget');
    });
  });
});

CI/CD Testing Pipeline

// .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        node-version: [18.x, 20.x]
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run linting
      run: npm run lint
    
    - name: Run type checking
      run: npm run type-check
    
    - name: Run unit tests
      run: npm run test:ci
    
    - name: Run integration tests
      run: npm run test:integration
    
    - name: Run E2E tests
      run: npm run test:e2e
      env:
        CI: true
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/lcov.info
        flags: unittests
        name: codecov-umbrella
    
    - name: Comment PR with coverage
      if: github.event_name == 'pull_request'
      uses: romeovs/lcov-reporter-action@v0.3.1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        lcov-file: ./coverage/lcov.info

  performance:
    runs-on: ubuntu-latest
    needs: test
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Use Node.js 20.x
      uses: actions/setup-node@v4
      with:
        node-version: 20.x
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build application
      run: npm run build
    
    - name: Run Lighthouse CI
      run: |
        npm install -g @lhci/cli@0.12.x
        lhci autorun
      env:
        LHCI_GITHUB_APP_TOKEN: "${{ secrets.LHCI_GITHUB_APP_TOKEN }}"

# scripts/test-setup.js
// Test environment setup for CI
const { execSync } = require('child_process');

// Set up test database
if (process.env.CI) {
  console.log('Setting up test environment...');
  
  // Start test services
  execSync('docker-compose -f docker-compose.test.yml up -d', { stdio: 'inherit' });
  
  // Wait for services to be ready
  execSync('npx wait-on http://localhost:3001/health', { stdio: 'inherit' });
  
  console.log('Test environment ready');
}

// scripts/test-parallel.js
// Run tests in parallel for faster CI
const { spawn } = require('child_process');

const testSuites = [
  'npm run test:unit',
  'npm run test:integration',
  'npm run test:e2e:smoke'
];

const promises = testSuites.map(command => {
  return new Promise((resolve, reject) => {
    const [cmd, ...args] = command.split(' ');
    const child = spawn(cmd, args, { stdio: 'inherit' });
    
    child.on('close', (code) => {
      if (code === 0) {
        resolve(command);
      } else {
        reject(new Error(`${command} failed with code ${code}`));
      }
    });
  });
});

Promise.all(promises)
  .then(() => {
    console.log('All test suites passed!');
    process.exit(0);
  })
  .catch((error) => {
    console.error('Test suite failed:', error.message);
    process.exit(1);
  });

// Quality gates configuration
// quality-gates.config.js
module.exports = {
  coverage: {
    statements: 80,
    branches: 75,
    functions: 80,
    lines: 80
  },
  performance: {
    budgets: [
      {
        path: '/**',
        timings: [
          { metric: 'first-contentful-paint', budget: 2000 },
          { metric: 'largest-contentful-paint', budget: 4000 },
          { metric: 'cumulative-layout-shift', budget: 0.1 }
        ]
      }
    ]
  },
  accessibility: {
    level: 'AA',
    runner: 'axe',
    threshold: 0
  }
};

Production Testing Results

94%

Test Coverage

2.1s

Test Suite Runtime

847

Total Tests

0

Flaky Tests

Conclusion

Comprehensive testing strategies with Jest and Testing Library provide the foundation for reliable React applications. The patterns and practices covered here have proven effective in production banking environments, ensuring code quality, preventing regressions, and enabling confident deployments.

Need Testing Strategy Consultation?

Building comprehensive testing strategies requires expertise in testing patterns, tools, and best practices. I help teams implement robust testing solutions for React applications.