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) ​
- Test behavior, not implementation — test what the user sees and does, not internal state
- Accessibility-first selectors —
getByRole,getByLabelTextovergetByTestId - Arrange-Act-Assert — clear structure in every test
- Test the contract — inputs (props) → outputs (rendered UI, callbacks called)
- 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 resortPattern 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