Skip to content

04 - Unit Testing ​

Why This Matters for Glomopay ​

HR explicitly listed unit tests. In fintech, untested code = liability. A wrong render, a missing error state, or a broken form can cause real financial damage. They want a testing philosophy, not just "I know how to write expect()".


Testing Philosophy (Say This in the Interview) ​

  1. Test behavior, not implementation — test what the user sees and does, not internal state
  2. Accessibility-first selectors — getByRole, getByLabelText over getByTestId
  3. Arrange-Act-Assert — clear structure in every test
  4. Test the contract — inputs (props) → outputs (rendered UI, callbacks called)
  5. Mock at boundaries — mock API calls and services, not internal hooks

React Testing Library Basics ​

Setup ​

tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Query Priority (Know This Order) ​

1. getByRole         — best, matches accessibility tree
2. getByLabelText    — great for form fields
3. getByPlaceholderText
4. getByText         — for non-interactive elements
5. getByDisplayValue — for filled form fields
6. getByTestId       — last resort

Pattern 1: Testing a Form Component ​

tsx
interface AmountInputProps {
  onAmountChange: (amount: number) => void;
  min?: number;
  max?: number;
}

// TEST
describe('AmountInput', () => {
  it('calls onAmountChange with parsed number', async () => {
    const user = userEvent.setup();
    const onAmountChange = vi.fn();

    render(<AmountInput onAmountChange={onAmountChange} />);

    const input = screen.getByRole('spinbutton', { name: /amount/i });
    await user.clear(input);
    await user.type(input, '500');

    expect(onAmountChange).toHaveBeenLastCalledWith(500);
  });

  it('shows error when amount is below minimum', async () => {
    const user = userEvent.setup();
    render(<AmountInput onAmountChange={vi.fn()} min={100} />);

    const input = screen.getByRole('spinbutton', { name: /amount/i });
    await user.clear(input);
    await user.type(input, '50');

    expect(screen.getByText(/minimum.*100/i)).toBeInTheDocument();
  });

  it('disables input when disabled prop is true', () => {
    render(<AmountInput onAmountChange={vi.fn()} disabled />);
    expect(screen.getByRole('spinbutton', { name: /amount/i })).toBeDisabled();
  });
});

Pattern 2: Testing Async / API Calls ​

tsx
// Component that fetches and displays data
function TransactionList({ userId }: { userId: string }) {
  const [transactions, setTransactions] = useState<Transaction[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  // ... fetch logic
}

// TEST
describe('TransactionList', () => {
  // Mock at the boundary (API module)
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it('shows loading state initially', () => {
    vi.spyOn(api, 'getTransactions').mockImplementation(() => new Promise(() => {})); // never resolves
    render(<TransactionList userId="123" />);
    expect(screen.getByRole('progressbar')).toBeInTheDocument();
  });

  it('renders transactions after loading', async () => {
    vi.spyOn(api, 'getTransactions').mockResolvedValue([
      { id: '1', amount: 500, merchant: 'Amazon', status: 'success' },
      { id: '2', amount: 200, merchant: 'Flipkart', status: 'pending' },
    ]);

    render(<TransactionList userId="123" />);

    // waitFor retries until assertion passes
    await waitFor(() => {
      expect(screen.getByText('Amazon')).toBeInTheDocument();
      expect(screen.getByText('Flipkart')).toBeInTheDocument();
    });
  });

  it('shows error message when API fails', async () => {
    vi.spyOn(api, 'getTransactions').mockRejectedValue(new Error('Network error'));

    render(<TransactionList userId="123" />);

    expect(await screen.findByText(/network error/i)).toBeInTheDocument();
    // findBy = getBy + waitFor (shorthand for async queries)
  });
});

Pattern 3: Testing with Context / Providers ​

tsx
// Helper to render with providers
function renderWithProviders(
  ui: React.ReactElement,
  { initialState, ...options }: RenderOptions & { initialState?: Partial<CheckoutState> } = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <CheckoutProvider initialState={initialState}>
        {children}
      </CheckoutProvider>
    );
  }
  return render(ui, { wrapper: Wrapper, ...options });
}

// TEST
describe('PaymentButton', () => {
  it('is disabled when no payment method is selected', () => {
    renderWithProviders(<PaymentButton />, {
      initialState: { paymentMethod: null, amount: 100 },
    });
    expect(screen.getByRole('button', { name: /pay/i })).toBeDisabled();
  });

  it('shows amount in button text', () => {
    renderWithProviders(<PaymentButton />, {
      initialState: { paymentMethod: 'card', amount: 500 },
    });
    expect(screen.getByRole('button', { name: /pay.*500/i })).toBeInTheDocument();
  });
});

Pattern 4: Testing Custom Hooks ​

tsx
import { renderHook, act } from '@testing-library/react';

describe('useCounter', () => {
  it('increments count', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

// For hooks that need providers
describe('useCheckout', () => {
  it('throws when used outside provider', () => {
    // Suppress console.error for expected error
    vi.spyOn(console, 'error').mockImplementation(() => {});

    expect(() => {
      renderHook(() => useCheckout());
    }).toThrow('useCheckout must be used within <CheckoutProvider>');
  });

  it('advances step on nextStep', () => {
    const wrapper = ({ children }: { children: React.ReactNode }) => (
      <CheckoutProvider>{children}</CheckoutProvider>
    );

    const { result } = renderHook(() => useCheckout(), { wrapper });

    act(() => {
      result.current.dispatch({ type: 'NEXT_STEP' });
    });

    expect(result.current.state.step).toBe(1);
  });
});

Pattern 5: Testing User Interactions (Multi-Step Flow) ​

tsx
describe('CheckoutFlow', () => {
  it('completes a full checkout flow', async () => {
    const user = userEvent.setup();
    const onComplete = vi.fn();

    render(<CheckoutFlow onComplete={onComplete} />);

    // Step 1: Enter amount
    await user.type(screen.getByLabelText(/amount/i), '1000');
    await user.click(screen.getByRole('button', { name: /next/i }));

    // Step 2: Select payment method
    await user.click(screen.getByRole('radio', { name: /card/i }));
    await user.click(screen.getByRole('button', { name: /next/i }));

    // Step 3: Review and confirm
    expect(screen.getByText(/1000/)).toBeInTheDocument();
    expect(screen.getByText(/card/i)).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: /confirm/i }));

    // Verify completion
    await waitFor(() => {
      expect(onComplete).toHaveBeenCalledWith(
        expect.objectContaining({ amount: 1000, method: 'card' })
      );
    });
  });
});

What to Test vs What NOT to Test ​

DO Test ​

  • User-visible behavior (renders correct text, buttons disabled/enabled)
  • Callback invocations (onSubmit called with right args)
  • Error states (API failure shows error message)
  • Edge cases (empty list, max values, invalid input)
  • Accessibility (elements have correct roles and labels)

DON'T Test ​

  • Implementation details (internal state values, private methods)
  • Styling (unless it's behavior-related like visibility)
  • Third-party library internals
  • Snapshot tests for complex components (brittle, low value)

Testing Checklist for Any Component ​

  • [ ] Renders correctly with required props
  • [ ] Handles missing/optional props gracefully
  • [ ] User interactions trigger correct callbacks
  • [ ] Loading state renders correctly
  • [ ] Error state renders correctly
  • [ ] Empty state renders correctly
  • [ ] Accessibility: elements have roles, labels, ARIA attributes

Frontend interview preparation reference.