Testing is where junior developers become professionals. Anyone can write code that works once—testing proves it keeps working.
Frontend testing has evolved beyond "just check if it renders." Modern interviews expect you to understand the testing pyramid, component testing patterns, and when to use different testing strategies.
This guide covers Jest, React Testing Library, and Cypress—the testing stack most companies use and most interviews ask about.
Testing Fundamentals
Before writing tests, understand what you're testing and why.
The Testing Pyramid
/\
/ \ E2E Tests
/ \ (few, slow, expensive)
/------\
/ \ Integration Tests
/ \ (some, moderate speed)
/------------\
/ \ Unit Tests
/________________\ (many, fast, cheap)
| Level | What It Tests | Speed | Confidence | Maintenance |
|---|---|---|---|---|
| Unit | Single function/component in isolation | Fast | Lower | Low |
| Integration | Multiple units working together | Medium | Medium | Medium |
| E2E | Full user flows in real browser | Slow | Highest | High |
The pyramid principle: More tests at the bottom, fewer at the top. Unit tests are fast and pinpoint failures. E2E tests are slow but catch integration issues.
What to Test
Do test:
- User interactions (clicks, form submissions)
- Conditional rendering
- Data transformations
- Error states
- Edge cases (empty lists, loading states)
Don't test:
- Implementation details (internal state, private methods)
- Third-party libraries
- Styling (unless critical)
- Things the framework already tests
Testing Philosophy
React Testing Library's guiding principle:
"The more your tests resemble the way your software is used, the more confidence they can give you."
This means:
- Query elements like users find them (by text, role, label)
- Test behavior, not implementation
- Avoid testing internal state directly
Jest Essentials
Jest is the standard JavaScript test runner. It handles test execution, assertions, mocking, and coverage.
Basic Test Structure
// sum.test.js
import { sum, multiply } from './math';
describe('math utilities', () => {
describe('sum', () => {
it('adds two positive numbers', () => {
expect(sum(1, 2)).toBe(3);
});
it('handles negative numbers', () => {
expect(sum(-1, -2)).toBe(-3);
});
it('handles zero', () => {
expect(sum(0, 5)).toBe(5);
});
});
describe('multiply', () => {
it('multiplies two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
});
});Common Matchers
// Equality
expect(value).toBe(3); // Strict equality (===)
expect(value).toEqual({ a: 1 }); // Deep equality for objects
expect(value).toStrictEqual(obj); // Deep equality + undefined checks
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(5);
expect(value).toBeCloseTo(0.3, 5); // Floating point
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
// Arrays
expect(array).toContain('item');
expect(array).toHaveLength(3);
expect(array).toEqual(expect.arrayContaining([1, 2]));
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ partial: 'match' });
// Exceptions
expect(() => badFunction()).toThrow();
expect(() => badFunction()).toThrow('specific message');
expect(() => badFunction()).toThrow(CustomError);Mocking Functions
// Create a mock function
const mockFn = jest.fn();
// Call it
mockFn('arg1', 'arg2');
// Assert on calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
// Mock return values
const mockFn = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first call')
.mockReturnValueOnce('second call');
// Mock implementation
const mockFn = jest.fn((x) => x * 2);
// Mock resolved/rejected values (async)
const mockAsync = jest.fn()
.mockResolvedValue({ data: 'success' })
.mockRejectedValueOnce(new Error('fail'));Mocking Modules
// Mock entire module
jest.mock('./api');
import { fetchUser } from './api';
// fetchUser is now a mock function
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
// Partial mock (keep some real implementations)
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
formatDate: jest.fn(() => '2026-01-01')
}));Spying on Methods
// Spy on existing method
const spy = jest.spyOn(object, 'method');
// Spy and mock implementation
jest.spyOn(console, 'error').mockImplementation(() => {});
// Restore original
spy.mockRestore();Async Testing
// Async/await
it('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// Promises
it('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
// Testing rejections
it('handles errors', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
// Fake timers
jest.useFakeTimers();
it('debounces input', () => {
const callback = jest.fn();
const debounced = debounce(callback, 500);
debounced();
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(callback).toHaveBeenCalled();
});Setup and Teardown
describe('database tests', () => {
// Run once before all tests in this describe
beforeAll(async () => {
await db.connect();
});
// Run before each test
beforeEach(async () => {
await db.clear();
});
// Run after each test
afterEach(() => {
jest.clearAllMocks();
});
// Run once after all tests
afterAll(async () => {
await db.disconnect();
});
it('inserts data', async () => {
// test code
});
});React Testing Library
React Testing Library (RTL) renders components and provides queries to find elements the way users would.
Basic Component Test
// Button.jsx
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});Query Priority
RTL provides multiple ways to find elements. Use them in this priority order:
// 1. Accessible by Everyone
screen.getByRole('button', { name: 'Submit' }) // Best - uses accessibility
screen.getByLabelText('Email') // For form fields
screen.getByPlaceholderText('Enter email') // Less preferred than label
screen.getByText('Welcome') // For non-interactive elements
screen.getByDisplayValue('current value') // For filled form fields
// 2. Semantic Queries
screen.getByAltText('Profile picture') // For images
screen.getByTitle('Close') // For title attributes
// 3. Test IDs (last resort)
screen.getByTestId('custom-element') // When nothing else worksWhy this order? Queries at the top ensure your app is accessible. If you can't find an element by role, your app might have accessibility issues.
Query Variants
// getBy - throws if not found (use when element should exist)
screen.getByText('Hello')
// queryBy - returns null if not found (use for asserting absence)
expect(screen.queryByText('Error')).not.toBeInTheDocument()
// findBy - returns promise, waits for element (use for async content)
await screen.findByText('Loaded data')
// Plural variants for multiple elements
screen.getAllByRole('listitem')
screen.queryAllByRole('button')
await screen.findAllByText(/item/)User Events
Always prefer userEvent over fireEvent—it simulates real user behavior more accurately.
import userEvent from '@testing-library/user-event';
it('handles user interactions', async () => {
const user = userEvent.setup();
render(<Form />);
// Typing
await user.type(screen.getByLabelText('Name'), 'Alice');
// Clicking
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Clearing and typing
await user.clear(screen.getByLabelText('Name'));
await user.type(screen.getByLabelText('Name'), 'Bob');
// Selecting options
await user.selectOptions(screen.getByRole('combobox'), 'option1');
// Keyboard
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
// Tab navigation
await user.tab();
// Hover
await user.hover(screen.getByText('Tooltip trigger'));
});Testing Async Components
// UserProfile.jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>Hello, {user.name}</div>;
}
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import { fetchUser } from './api';
jest.mock('./api');
describe('UserProfile', () => {
it('shows loading state then user data', async () => {
fetchUser.mockResolvedValue({ name: 'Alice' });
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for user data
expect(await screen.findByText('Hello, Alice')).toBeInTheDocument();
// Loading is gone
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('handles fetch error', async () => {
fetchUser.mockRejectedValue(new Error('Failed'));
render(<UserProfile userId={1} />);
expect(await screen.findByText('Error loading user')).toBeInTheDocument();
});
});waitFor for Complex Async
import { render, screen, waitFor } from '@testing-library/react';
it('updates after async action', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: 'Increment' }));
// Wait for specific condition
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// Or with timeout
await waitFor(
() => expect(screen.getByText('Count: 1')).toBeInTheDocument(),
{ timeout: 3000 }
);
});Component Testing Patterns
Common patterns for testing React components.
Testing Props
// Greeting.jsx
function Greeting({ name, formal = false }) {
return formal ? <p>Good day, {name}</p> : <p>Hey {name}!</p>;
}
// Greeting.test.jsx
describe('Greeting', () => {
it('renders informal greeting by default', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText('Hey Alice!')).toBeInTheDocument();
});
it('renders formal greeting when formal prop is true', () => {
render(<Greeting name="Alice" formal />);
expect(screen.getByText('Good day, Alice')).toBeInTheDocument();
});
});Testing Hooks
// useCounter.js
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});Testing Context
// ThemeContext.jsx
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ThemedButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import ThemedButton from './ThemedButton';
// Custom render with providers
function renderWithTheme(ui) {
return render(
<ThemeProvider>{ui}</ThemeProvider>
);
}
describe('ThemedButton', () => {
it('uses theme from context', () => {
renderWithTheme(<ThemedButton />);
expect(screen.getByRole('button')).toHaveClass('light');
});
it('toggles theme on click', async () => {
renderWithTheme(<ThemedButton />);
await userEvent.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toHaveClass('dark');
});
});Testing Forms
// LoginForm.jsx
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError('All fields required');
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
{error && <p role="alert">{error}</p>}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit">Log in</button>
</form>
);
}
// LoginForm.test.jsx
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
it('shows error when fields are empty', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByRole('alert')).toHaveTextContent('All fields required');
});
});Testing Error Boundaries
// ErrorBoundary.test.jsx
import { render, screen } from '@testing-library/react';
import ErrorBoundary from './ErrorBoundary';
// Component that throws
function BrokenComponent() {
throw new Error('Test error');
}
describe('ErrorBoundary', () => {
// Suppress console.error for cleaner test output
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
console.error.mockRestore();
});
it('renders children when no error', () => {
render(
<ErrorBoundary>
<div>Content</div>
</ErrorBoundary>
);
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('renders fallback when child throws', () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<BrokenComponent />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});Mocking Strategies
Effective mocking isolates components and controls test conditions.
Mocking API Calls
// Using jest.mock
jest.mock('./api');
import { fetchUsers } from './api';
beforeEach(() => {
fetchUsers.mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
});
it('displays users from API', async () => {
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});MSW (Mock Service Worker)
MSW intercepts network requests—closer to real behavior than mocking modules.
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
})
];
// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// In tests, override handlers as needed
it('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText('Failed to load users')).toBeInTheDocument();
});Mocking Timers
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('shows message after delay', async () => {
render(<DelayedMessage delay={5000} />);
expect(screen.queryByText('Hello!')).not.toBeInTheDocument();
// Fast-forward time
jest.advanceTimersByTime(5000);
expect(screen.getByText('Hello!')).toBeInTheDocument();
});
it('debounces search input', async () => {
const onSearch = jest.fn();
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
await userEvent.type(screen.getByRole('textbox'), 'test');
// Not called yet (debounced)
expect(onSearch).not.toHaveBeenCalled();
// Fast-forward debounce time
jest.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith('test');
});Mocking Browser APIs
// Mocking localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn()
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mocking window.matchMedia
Object.defineProperty(window, 'matchMedia', {
value: jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn()
}))
});
// Mocking IntersectionObserver
global.IntersectionObserver = class {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
// Mocking ResizeObserver
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};End-to-End Testing
E2E tests run in real browsers and test complete user flows.
Cypress Basics
// cypress/e2e/login.cy.js
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully', () => {
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('password123');
cy.get('button[type="submit"]').click();
// Assert redirect and welcome message
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('shows error for invalid credentials', () => {
cy.get('[data-testid="email"]').type('wrong@example.com');
cy.get('[data-testid="password"]').type('wrongpassword');
cy.get('button[type="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
cy.url().should('include', '/login');
});
});Cypress Best Practices
// Custom commands (cypress/support/commands.js)
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
// Using custom command
describe('Dashboard', () => {
beforeEach(() => {
cy.login('user@example.com', 'password123');
cy.visit('/dashboard');
});
it('displays user data', () => {
cy.contains('Welcome back').should('be.visible');
});
});
// Intercepting API calls
it('displays data from API', () => {
cy.intercept('GET', '/api/users', {
fixture: 'users.json'
}).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-testid="user-card"]').should('have.length', 3);
});Playwright Alternative
// tests/login.spec.js
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('logs in successfully', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByText('Welcome back')).toBeVisible();
});
});
// API mocking in Playwright
test('handles API error', async ({ page }) => {
await page.route('/api/users', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' })
});
});
await page.goto('/users');
await expect(page.getByText('Failed to load')).toBeVisible();
});Testing Best Practices
What to Test
// DO: Test user-visible behavior
it('shows error message on invalid email', async () => {
render(<SignupForm />);
await userEvent.type(screen.getByLabelText('Email'), 'notanemail');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument();
});
// DON'T: Test implementation details
it('sets isValid state to false', () => {
// Testing internal state is fragile and doesn't verify user experience
});Test Structure (AAA Pattern)
it('adds item to cart', async () => {
// Arrange - set up the test
const product = { id: 1, name: 'Widget', price: 10 };
render(<ProductCard product={product} />);
// Act - perform the action
await userEvent.click(screen.getByRole('button', { name: 'Add to cart' }));
// Assert - verify the result
expect(screen.getByText('Added to cart')).toBeInTheDocument();
});Avoiding Brittle Tests
// BRITTLE: Depends on exact text
expect(screen.getByText('You have 3 items in your cart')).toBeInTheDocument();
// ROBUST: Uses regex for flexible matching
expect(screen.getByText(/3 items/i)).toBeInTheDocument();
// BRITTLE: Depends on DOM structure
container.querySelector('div > ul > li:first-child');
// ROBUST: Uses accessible queries
screen.getByRole('listitem', { name: 'First item' });
// BRITTLE: Testing exact classnames
expect(element).toHaveClass('btn-primary-lg-active');
// ROBUST: Testing visible behavior
expect(element).toBeVisible();
expect(element).toBeEnabled();Test Isolation
// Each test should be independent
describe('Counter', () => {
// Reset state between tests
afterEach(() => {
jest.clearAllMocks();
});
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('increments on click', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button'));
expect(screen.getByText('1')).toBeInTheDocument();
});
// This test doesn't depend on the previous one passing
});Snapshot Testing Guidelines
// GOOD: Small, focused snapshots
it('renders button with correct attributes', () => {
const { container } = render(<IconButton icon="save" />);
expect(container.firstChild).toMatchInlineSnapshot(`
<button
aria-label="Save"
class="icon-button"
>
<svg ... />
</button>
`);
});
// BAD: Large component snapshots
it('renders entire page', () => {
const { container } = render(<EntireDashboard />);
expect(container).toMatchSnapshot(); // 1000+ lines, always blindly updated
});Quick Reference
Query priority:
getByRole- accessibility firstgetByLabelText- form fieldsgetByText- non-interactive elementsgetByTestId- last resort
Query variants:
getBy- element should exist (throws)queryBy- test absence (returns null)findBy- async content (returns promise)
User events:
- Always use
userEventoverfireEvent - Always
awaituser events - Use
userEvent.setup()for proper event simulation
Mocking:
jest.mock()for modulesjest.fn()for functions- MSW for API calls
jest.useFakeTimers()for timers
Test structure:
- Arrange → Act → Assert
- One assertion concept per test
- Descriptive test names
Related Articles
- Complete Frontend Developer Interview Guide - Full guide to frontend interviews
- React Advanced Interview Guide - React patterns and testing
- Testing Strategies Interview Guide - Broader testing concepts
- JavaScript Tricky Questions Interview Guide - JavaScript edge cases
What's Next?
Testing is a skill that improves with practice. Start with simple component tests, then add complexity as you get comfortable.
In interviews, demonstrate that you think about testing as part of development, not an afterthought. Explain what you'd test and why. Show you understand the trade-offs between test types.
