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 50+ frontend testing interview questions spanning Jest, React Testing Library, and Cypress—the testing stack most companies use and most interviews ask about.
Table of Contents
- Testing Fundamentals Questions
- Jest Essentials Questions
- React Testing Library Questions
- Component Testing Pattern Questions
- Mocking Strategy Questions
- End-to-End Testing Questions
- Testing Best Practices Questions
Testing Fundamentals Questions
Understanding testing philosophy and strategy is as important as knowing the tools. These foundational concepts come up in almost every testing interview.
What is the testing pyramid and why does it matter?
The testing pyramid is a visual metaphor for structuring your test suite. It suggests having many unit tests at the base (fast, isolated, cheap), fewer integration tests in the middle (moderate speed, test component interactions), and even fewer E2E tests at the top (slow, expensive, but highest confidence).
The pyramid matters because it guides resource allocation. Unit tests run in milliseconds and pinpoint exactly which function failed. E2E tests take seconds or minutes and might fail for many reasons—network issues, timing problems, or actual bugs. A balanced pyramid gives confidence without creating a slow CI pipeline that developers start to ignore.
flowchart TB
subgraph pyramid["Testing Pyramid"]
E2E["E2E Tests<br/>(few, slow, expensive)"]
INT["Integration Tests<br/>(some, moderate speed)"]
UNIT["Unit Tests<br/>(many, fast, cheap)"]
end
E2E --> INT --> UNIT| 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 |
What should you test in frontend applications?
Testing strategy should focus on user-visible behavior and critical paths through your application. The goal is confidence that your app works correctly, not achieving arbitrary coverage metrics. Good tests verify what users experience, not implementation details.
Test user interactions like clicks and form submissions, conditional rendering based on props or state, data transformations and business logic, error states and edge cases like empty lists or loading states. Avoid testing implementation details like internal state values, third-party libraries that have their own tests, styling unless it's functionally critical, or things the framework already guarantees.
What is the React Testing Library philosophy?
React Testing Library's guiding principle states: "The more your tests resemble the way your software is used, the more confidence they can give you." This philosophy fundamentally shapes how you write tests—focusing on user behavior rather than component internals.
This means querying elements the way users find them (by visible text, accessible roles, form labels), testing behavior rather than implementation, and avoiding direct assertions on internal state. If your test uses component.state() or checks specific CSS classes, you're likely testing implementation details that could change without affecting user experience.
What is the difference between Jest and React Testing Library?
Jest and React Testing Library serve different purposes and work together in a typical React testing setup. Understanding their distinct roles helps you use each tool effectively and explain your testing approach in interviews.
Jest is a test runner and assertion library. It discovers test files, executes them, provides matchers like expect().toBe(), handles mocking, measures code coverage, and reports results. Jest works with any JavaScript code, not just React. React Testing Library is specifically for rendering React components and querying the resulting DOM. It provides render() to mount components and queries like getByRole() to find elements. RTL doesn't run tests—Jest does. RTL doesn't make assertions—Jest's expect() does.
Jest Essentials Questions
Jest is the standard JavaScript test runner. Mastering its core features—assertions, mocking, and async testing—is essential for any frontend testing interview.
How do you structure a Jest test file?
Jest test files use describe blocks to group related tests and it (or test) blocks for individual test cases. This structure creates readable test output and helps organize tests logically by feature or component behavior.
The describe function creates a test suite that can be nested for hierarchical organization. Each it block should test one specific behavior with a descriptive name that reads like a sentence. Good test names explain what the code should do, making failures self-documenting.
// 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);
});
});
});What are the most common Jest matchers?
Jest matchers are methods that let you test values in different ways. The expect() function returns an object with matcher methods, and choosing the right matcher makes your tests more expressive and your failure messages more helpful.
Understanding when to use toBe() versus toEqual() is particularly important—toBe() uses strict equality (===) for primitives, while toEqual() performs deep equality for objects and arrays. Using the wrong one is a common source of confusing test failures.
// 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 comparison
// Strings and Arrays
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
expect(array).toContain('item');
expect(array).toHaveLength(3);
// 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);How do you create and use mock functions in Jest?
Mock functions let you replace real implementations with controlled versions that you can inspect and configure. They're essential for isolating the code under test and verifying that functions are called correctly without executing their real behavior.
jest.fn() creates a mock function that tracks all calls, arguments, and return values. You can configure what the mock returns, make it throw errors, or provide a custom implementation. After the test, you can assert on how the mock was called.
// 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 mockWithReturns = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first call')
.mockReturnValueOnce('second call');
// Mock implementation
const mockWithImpl = jest.fn((x) => x * 2);
// Mock resolved/rejected values (async)
const mockAsync = jest.fn()
.mockResolvedValue({ data: 'success' })
.mockRejectedValueOnce(new Error('fail'));How do you mock modules in Jest?
Module mocking replaces entire imported modules with mock versions, allowing you to control dependencies without modifying the code under test. This is essential for testing code that depends on APIs, databases, or other external services.
jest.mock() hoists to the top of the file and replaces the module before any imports execute. All exported functions become mock functions automatically. For partial mocks where you want to keep some real implementations, use jest.requireActual().
// 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(() => '2025-01-01')
}));
// Spy on existing method without replacing module
const spy = jest.spyOn(object, 'method');
jest.spyOn(console, 'error').mockImplementation(() => {});
// Restore original implementation
spy.mockRestore();How do you test asynchronous code in Jest?
Asynchronous testing requires telling Jest to wait for promises to resolve or reject before making assertions. Without proper async handling, tests complete before the async code finishes, leading to false positives or confusing failures.
The most common approach is async/await syntax, which reads naturally and handles both success and error cases. For testing rejections, use rejects matcher. For code using timers, Jest's fake timers let you control time without actually waiting.
// Async/await (preferred)
it('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// Testing rejections
it('handles errors', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
// Fake timers for setTimeout/setInterval
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();
});What are setup and teardown functions in Jest?
Setup and teardown functions run code before and after tests, ensuring consistent test conditions and proper cleanup. They're essential for tests that need shared setup, database connections, or mock configuration.
beforeEach and afterEach run before/after every test in the describe block—use them for resetting state between tests. beforeAll and afterAll run once for the entire suite—use them for expensive setup like database connections. These functions can be async if needed.
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 - database is connected and cleared
});
});React Testing Library Questions
React Testing Library (RTL) renders components and provides queries to find elements the way users would. Understanding RTL's query system and user event simulation is crucial for modern React testing.
How do you write a basic component test with RTL?
A basic RTL test renders a component, finds elements using queries, simulates user interactions, and asserts on the results. The pattern follows how a real user would interact with your component—they see text, click buttons, and observe changes.
Use render() to mount the component, screen to access queries, userEvent for interactions, and Jest's expect() for assertions. Always prefer userEvent over fireEvent because it simulates real user behavior more accurately, including focus changes and event sequences.
// 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);
});
});What is the query priority in React Testing Library?
RTL provides multiple ways to find elements, and using them in the recommended priority order ensures your tests verify accessibility while being resilient to implementation changes. The priority reflects how users actually find elements on a page.
Queries at the top of the priority list (getByRole, getByLabelText) ensure your app is accessible. If you can't find an element by role, your app might have accessibility issues that affect real users with screen readers. Only fall back to getByTestId when semantic queries aren't possible.
// 1. Accessible by Everyone (use these first)
screen.getByRole('button', { name: 'Submit' }) // Best - uses ARIA roles
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 inputs
// 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 worksWhat is the difference between getBy, queryBy, and findBy?
These three query variants handle element presence differently, and choosing the right one makes your tests more expressive and your error messages more helpful. Each serves a specific testing scenario.
getBy throws immediately if the element isn't found—use it when the element should definitely exist. queryBy returns null instead of throwing—use it when asserting an element should NOT exist. findBy returns a promise and waits up to 1 second for the element to appear—use it for async content that renders after data fetching.
// getBy - throws if not found (element should exist)
screen.getByText('Hello') // Throws if not found
// queryBy - returns null if not found (test absence)
expect(screen.queryByText('Error')).not.toBeInTheDocument()
// findBy - returns promise, waits for element (async content)
await screen.findByText('Loaded data')
// Plural variants for multiple elements
screen.getAllByRole('listitem') // Throws if none found
screen.queryAllByRole('button') // Returns empty array if none
await screen.findAllByText(/item/) // Waits for at least oneHow do you simulate user interactions with userEvent?
userEvent simulates real user behavior more accurately than fireEvent. It triggers the same sequence of events that a real user interaction would cause—focus changes, keyboard events, and proper event ordering.
Always use userEvent.setup() at the start of tests for proper event simulation. All userEvent methods are async and must be awaited. This matches real browser behavior where events are processed asynchronously.
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'));
});How do you test async components that fetch data?
Testing async components requires mocking the data source and using findBy queries that wait for content to appear. The test should verify both loading states and the final rendered content after data arrives.
Mock the API module to control what data is returned, render the component, assert on the loading state, then wait for the loaded content. Using findBy is cleaner than wrapping assertions in waitFor for simple cases.
// UserProfile.jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error loading user</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();
});
});When should you use waitFor?
waitFor repeatedly runs a callback until it passes or times out, useful for complex async scenarios where findBy isn't sufficient. Use it when you need to wait for multiple conditions or when the element might already be present but hasn't updated yet.
findBy is shorthand for waitFor + getBy and is preferred for waiting for elements to appear. Use waitFor explicitly when you need to assert on multiple things, wait for element updates rather than appearance, or need custom timeout or interval settings.
import { render, screen, waitFor } from '@testing-library/react';
it('updates counter 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();
});
// With custom timeout
await waitFor(
() => expect(screen.getByText('Count: 1')).toBeInTheDocument(),
{ timeout: 3000 }
);
});Component Testing Pattern Questions
These patterns address common testing scenarios for React components—props, hooks, context, forms, and error boundaries.
How do you test components with different prop combinations?
Testing props verifies that components render correctly based on their inputs. Create separate test cases for default values, different prop combinations, and edge cases. This ensures the component handles all expected inputs correctly.
Structure tests to clearly show the relationship between inputs and outputs. For components with many props, consider using a helper function that provides defaults so each test only specifies the props relevant to that test case.
// 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();
});
it('handles empty name', () => {
render(<Greeting name="" />);
expect(screen.getByText('Hey !')).toBeInTheDocument();
});
});How do you test custom hooks?
Custom hooks can't be tested directly because hooks must be called within a React component. RTL's renderHook utility handles this by rendering the hook in a test component and providing access to its return value.
Use act() to wrap any code that triggers state updates—this ensures React processes the updates before you make assertions. The result.current property always reflects the hook's current return value.
// 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);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});How do you test components that use Context?
Components that consume context need that context provided during testing. Create a custom render function that wraps components with the necessary providers, or render the provider explicitly in each test.
For complex apps with multiple providers, a custom render function keeps tests clean. For simple cases, wrapping inline is fine. Either way, you're testing the component as it would actually be used.
// ThemeContext.jsx
const ThemeContext = createContext();
function ThemeProvider({ children, initialTheme = 'light' }) {
const [theme, setTheme] = useState(initialTheme);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// test-utils.js - Custom render with providers
function renderWithTheme(ui, { initialTheme = 'light' } = {}) {
return render(
<ThemeProvider initialTheme={initialTheme}>{ui}</ThemeProvider>
);
}
// ThemedButton.test.jsx
describe('ThemedButton', () => {
it('uses theme from context', () => {
renderWithTheme(<ThemedButton />);
expect(screen.getByRole('button')).toHaveClass('light');
});
it('starts with dark theme when provided', () => {
renderWithTheme(<ThemedButton />, { initialTheme: 'dark' });
expect(screen.getByRole('button')).toHaveClass('dark');
});
it('toggles theme on click', async () => {
renderWithTheme(<ThemedButton />);
await userEvent.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toHaveClass('dark');
});
});How do you test form submission and validation?
Form testing should verify that user input reaches the submit handler correctly and that validation errors appear when expected. Test the happy path (valid submission) and error cases (validation failures) separately.
Use accessible queries to find form fields by their labels—this verifies your form is accessible while testing functionality. Simulate realistic user input with userEvent.type() rather than directly setting values.
// 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');
});
it('shows error for invalid email format', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.type(screen.getByLabelText('Email'), 'notanemail');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email');
});
});How do you test Error Boundaries?
Error boundaries catch JavaScript errors in child components and display fallback UI. Testing them requires rendering a component that throws an error and verifying the fallback appears instead of crashing the test.
Suppress console.error during these tests to keep test output clean—React logs errors to the console even when properly caught by 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 fallback={<div>Error</div>}>
<div>Content</div>
</ErrorBoundary>
);
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.queryByText('Error')).not.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 Strategy Questions
Effective mocking isolates components and controls test conditions. These questions cover API mocking, timers, and browser APIs.
How do you mock API calls in component tests?
API mocking lets you test components that fetch data without making real network requests. You control what data is returned, simulate errors, and verify the component handles all cases correctly.
The simplest approach is jest.mock() to replace the API module. For more realistic testing, Mock Service Worker (MSW) intercepts actual network requests, which catches issues that module mocking might miss.
// 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(screen.getByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('handles API error', async () => {
fetchUsers.mockRejectedValue(new Error('Network error'));
render(<UserList />);
expect(await screen.findByText('Failed to load users')).toBeInTheDocument();
});How do you use Mock Service Worker (MSW) for API mocking?
MSW intercepts network requests at the service worker level, providing more realistic testing than module mocking. Your code makes actual fetch or axios calls that MSW intercepts and responds to with your mock data.
This approach catches issues that module mocking misses—like incorrect URLs, missing headers, or request body problems. MSW handlers can be shared between tests and can even be used in development for API mocking.
// 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
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();
});How do you test components that use timers?
Components using setTimeout, setInterval, or debounce can be tested with Jest's fake timers. Instead of actually waiting for time to pass, you can advance the fake clock instantly and verify the expected behavior.
Enable fake timers before tests and restore real timers afterward. Use advanceTimersByTime() to move time forward or runAllTimers() to execute all pending timers immediately.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('shows message after delay', () => {
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');
});How do you mock browser APIs like localStorage or matchMedia?
Browser APIs not available in the test environment need to be mocked. Common examples include localStorage, matchMedia for responsive design, IntersectionObserver for lazy loading, and ResizeObserver for size tracking.
Mock these APIs before tests run, either in a setup file or in individual test files. The mocks should provide the minimum interface your code uses.
// Mocking localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: 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 Questions
E2E tests run in real browsers and test complete user flows. Understanding Cypress and Playwright is important for comprehensive testing coverage.
How do you write a basic Cypress test?
Cypress tests use a chainable API to interact with pages and make assertions. Tests visit a URL, find elements using selectors, perform actions, and verify results. Cypress automatically waits for elements and retries assertions, making async handling mostly automatic.
Cypress commands are not promises but use a command queue that executes sequentially. This means you don't need await but also can't use regular JavaScript control flow with command results directly.
// 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');
});
});How do you create reusable commands and handle authentication in Cypress?
Custom commands encapsulate repeated actions like logging in, and cy.session() caches authentication state so tests don't need to log in every time. This dramatically speeds up test suites.
Define custom commands in the support file. The session command caches cookies, localStorage, and sessionStorage, restoring them for tests that use the same session key.
// 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 in tests
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);
});How does Playwright compare to Cypress?
Playwright is a newer E2E testing framework that offers some advantages over Cypress. It supports multiple browsers (Chromium, Firefox, WebKit), runs tests in parallel out of the box, and has a more straightforward async/await API.
Playwright's API uses async/await, so you can use normal JavaScript control flow with test results. It also supports testing across multiple browser contexts simultaneously, useful for testing real-time collaboration features.
// 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();
});When should you use E2E tests vs integration tests?
E2E and integration tests serve different purposes and have different trade-offs. Understanding when to use each helps you build an efficient test suite that provides confidence without excessive slowness.
Use E2E tests for critical user journeys where bugs would significantly impact business—login flows, checkout processes, and core features. E2E tests run in real browsers and catch issues that component tests miss, like CSS problems, browser-specific behavior, or real network issues. Use integration tests for everything else—they're faster, easier to debug, and sufficient for most functionality testing.
E2E tests are appropriate for:
- Critical user journeys (signup, checkout)
- Flows involving multiple pages
- Features requiring real browser behavior
- Smoke tests for deployment verification
Integration tests are better for:
- Component interactions
- Form validation and submission
- API integration with mocked backend
- Most feature testing
Testing Best Practices Questions
These questions assess your understanding of testing principles and how to write maintainable tests.
What is the AAA pattern for structuring tests?
The Arrange-Act-Assert (AAA) pattern organizes tests into three clear phases: set up the test conditions, perform the action being tested, and verify the results. This structure makes tests readable and easy to understand at a glance.
Following AAA consistently helps other developers understand what each test does and makes debugging easier when tests fail. Keep each section focused—if arrange is very long, consider extracting setup to a helper function.
it('adds item to cart', async () => {
// Arrange - set up the test conditions
const product = { id: 1, name: 'Widget', price: 10 };
render(<ProductCard product={product} />);
// Act - perform the action being tested
await userEvent.click(screen.getByRole('button', { name: 'Add to cart' }));
// Assert - verify the expected result
expect(screen.getByText('Added to cart')).toBeInTheDocument();
});How do you avoid brittle tests?
Brittle tests break when implementation changes even though behavior is correct. They're expensive to maintain and erode confidence in the test suite. Writing resilient tests requires focusing on user-visible behavior rather than implementation details.
Use flexible matchers like regex instead of exact strings, query by accessible roles instead of DOM structure, and assert on visible outcomes rather than internal state. If a test breaks frequently for non-bug reasons, it's testing the wrong thing.
// 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 implementation details
expect(component.state.isValid).toBe(false);
// ROBUST: Testing user-visible behavior
expect(screen.getByText('Please fix the errors')).toBeInTheDocument();How do you ensure test isolation?
Test isolation means each test can run independently in any order with the same result. Tests that depend on each other or shared state cause intermittent failures that are hard to debug.
Reset state between tests using beforeEach and afterEach. Clear mocks after each test. Don't rely on test execution order. Each test should set up exactly what it needs and clean up after itself.
describe('Counter', () => {
// Reset mocks between tests
afterEach(() => {
jest.clearAllMocks();
});
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('increments on click', async () => {
// This test doesn't depend on the previous one
render(<Counter />);
await userEvent.click(screen.getByRole('button'));
expect(screen.getByText('1')).toBeInTheDocument();
});
});When should you use snapshot testing?
Snapshot testing captures the rendered output and compares it to a saved reference. It's useful for detecting unintended changes but can become a maintenance burden if overused or applied to frequently-changing components.
Use snapshots for small, stable components where structure matters, like icon components or formatted output. Avoid them for large components, frequently-changing UI, or as a substitute for behavioral tests. When a snapshot fails, developers often blindly update without verifying the change was intentional.
// 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(); // Hundreds of lines, blindly updated
});Quick Reference
| Topic | Key Points |
|---|---|
| Testing Pyramid | Many unit tests, fewer integration, fewest E2E |
| Query Priority | getByRole → getByLabelText → getByText → getByTestId |
| Query Variants | getBy (exists), queryBy (absence), findBy (async) |
| User Events | Always use userEvent over fireEvent, always await |
| Mocking | jest.mock() for modules, jest.fn() for functions, MSW for APIs |
| Test Structure | Arrange → Act → Assert pattern |
| Async Testing | findBy for appearing elements, waitFor for conditions |
Related Resources
- React Advanced Interview Guide - React patterns and architecture
- JavaScript Interview Guide - JavaScript fundamentals
- TypeScript Interview Guide - Type-safe testing patterns
