33 Frontend Testing Interview Questions and Answers

·32 min read
By ·Updated
testingjestreact-testing-librarycypressfrontendinterview-preparation

Testing is evidence, not proof that software will always work and not a measure of developer seniority by itself. Strong frontend testing answers connect product risk to an appropriate environment, observable behavior, controlled data, useful failure diagnostics, and maintenance cost.

This guide covers 33 questions across Jest and Vitest-style unit runners, React Testing Library, Mock Service Worker, Cypress, Playwright, component tests, and end-to-end strategy. Tool choice depends on the repository and role; no single stack represents most companies by default.

Table of Contents

  1. Testing Fundamentals Questions
  2. Jest Essentials Questions
  3. React Testing Library Questions
  4. Component Testing Pattern Questions
  5. Mocking Strategy Questions
  6. End-to-End Testing Questions
  7. Testing Best Practices Questions

Testing Fundamentals Questions

Testing strategy is as important as tool syntax. Align the answer with the product's risks and delivery constraints.

What is the testing pyramid and why does it matter?

The testing pyramid is a visual heuristic that suggests many small-scope tests, fewer integration tests, and a smaller set of broad end-to-end tests. It captures a common cost gradient, not a mandatory ratio or hierarchy of truth.

Small tests can be fast and diagnostic but miss wiring, browser, or deployment faults. Broad tests can exercise valuable boundaries but may cost more to run and diagnose. Actual runtime and confidence depend on architecture, environment, parallelism, data control, and test design. Track defect escapes, flake, duration, diagnosis time, and maintenance rather than optimizing the diagram itself.

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
LevelWhat It TestsSpeedConfidenceMaintenance
UnitSmall unit or pure contractOften fastNarrow logic evidenceUsually localized
Integration/componentMultiple units or rendered component boundaryVariesWiring and interaction evidenceDepends on environment
E2EUser flow across selected real boundariesOften slowerBroad path evidenceData, environment, and diagnosis cost

What should you test in frontend applications?

Testing strategy should cover important user-visible behavior and lower-level invariants whose failure would be costly. The goal is decision-quality evidence, not an arbitrary coverage number.

Test critical interactions, data transformations, authorization-sensitive states, loading/empty/error/retry behavior, accessibility, and supported browser behavior. Avoid duplicating a dependency's own tests, but test your integration contract. CSS deserves browser or visual checks when layout, focus visibility, reflow, or interaction depends on it.

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 can work together, but RTL can also use Vitest or another compatible runner.

Jest provides test discovery/execution, expectations, mocking, snapshots, and coverage integration. React Testing Library renders React components and provides DOM queries. @testing-library/jest-dom supplies additional DOM matchers. In a Vite application, Vitest may reduce transform/config duplication while offering a Jest-like API, but compatibility is not perfect and migration must verify mocks, timers, environment, and modules.


Jest Essentials Questions

Jest is a widely used JavaScript testing framework; Vitest is another common choice, especially in Vite-based projects. Interviews usually assess the underlying skills—clear assertions, controlled dependencies, async behavior, isolation, and useful failures—rather than loyalty to one runner.

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.

In Jest's CommonJS or transformed-module workflow, jest.mock() calls are commonly hoisted so the mock is registered before imports execute. Native ECMAScript modules evaluate static imports first, so they need a different workflow such as jest.unstable_mockModule() followed by a dynamic import(), or a design that injects the dependency. Automatic mocks replace exported functions with mock functions; a factory plus jest.requireActual() can create a partial CommonJS-style mock.

// 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');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
 
// Restore original implementations
spy.mockRestore();
consoleSpy.mockRestore();

For native ESM, check the Jest version's ESM guidance before copying a CommonJS mock pattern. unstable_mockModule is intentionally marked unstable, and ESM mocking requires dynamic imports after mock registration.

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; restore spies in suites that create them
  afterEach(() => {
    jest.clearAllMocks();
    jest.restoreAllMocks();
  });
 
  // 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, a userEvent.setup() instance for ordinary interactions, and the runner's expect() for assertions. userEvent models interaction sequences such as focus and keyboard events; fireEvent remains useful for low-level events that userEvent does not cover.

// 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();
    const user = userEvent.setup();
    render(<Button onClick={handleClick}>Click me</Button>);
 
    await user.click(screen.getByRole('button', { name: 'Click me' }));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

What is the query priority in React Testing Library?

RTL provides multiple ways to find elements. Its recommended priority favors queries that resemble how users and assistive technologies identify the UI, which can make tests more meaningful and less coupled to markup structure.

Queries such as getByRole and getByLabelText can expose missing names or labels, but passing these queries does not prove accessibility. Automated accessibility checks and keyboard, screen-reader, contrast, focus, and browser testing cover risks a DOM query cannot. Use getByTestId when a semantic query is unavailable or when a test ID is an intentional testing contract.

// 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 works

What 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 if exactly one expected element is not found, so use it for content that should exist now. queryBy returns null when no element matches, making it useful for absence assertions. findBy returns a promise and retries the corresponding getBy query until it succeeds or the configured timeout expires; the library default is commonly 1,000 ms but can be changed.

// 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 one

How 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.

Create a userEvent.setup() instance inside the test and await interactions that return promises. This is the recommended default API, while fireEvent is still appropriate for lower-level event cases that have no userEvent convenience method.

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(() => {
    let ignore = false;
    setLoading(true);
    setError(null);
 
    fetchUser(userId)
      .then(result => {
        if (!ignore) setUser(result);
      })
      .catch(fetchError => {
        if (!ignore) setError(fetchError);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });
 
    return () => {
      ignore = true;
    };
  }, [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 retries a callback until it stops throwing or times out. It is useful for a focused non-element condition, such as waiting for a mock to be called. Keep actions outside the callback: a side effect inside waitFor can run more than once.

Prefer findBy for an element that should appear and waitForElementToBeRemoved for disappearance. Use waitFor when no dedicated query expresses the condition, and keep the callback small enough that a failure identifies the actual problem.

import { render, screen, waitFor } from '@testing-library/react';
 
it('updates counter after async action', async () => {
  const user = userEvent.setup();
  render(<Counter />);
 
  await user.click(screen.getByRole('button', { name: 'Increment' }));
 
  // Wait for specific condition
  await waitFor(() => {
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
 
  // Use options such as { timeout: 3000 } only when the system contract needs it.
});

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?

Hooks must run within React. For user-visible behavior, prefer testing a component that consumes the hook. When the hook itself exposes a reusable contract, RTL's renderHook utility supplies the React wrapper and access to its current return value.

Use act() for state-changing hook calls made directly by the test. Testing Library's render and user-event utilities already coordinate many updates, so wrapping every line in act() is neither necessary nor a fix for un-awaited asynchronous work. The result.current property reflects the hook's latest 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 () => {
    const user = userEvent.setup();
    renderWithTheme(<ThemedButton />);
 
    await user.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 label-based queries for form fields; this tests the labeling contract and can expose some accessibility problems, although it does not replace a full accessibility review. Simulate realistic input with a userEvent instance rather than directly assigning values.

// LoginForm.test.jsx
describe('LoginForm', () => {
  it('submits form with email and password', async () => {
    const handleSubmit = jest.fn();
    const user = userEvent.setup();
    render(<LoginForm onSubmit={handleSubmit} />);
 
    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Log in' }));
 
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
 
  it('shows error when fields are empty', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={jest.fn()} />);
 
    await user.click(screen.getByRole('button', { name: 'Log in' }));
 
    expect(screen.getByRole('alert')).toHaveTextContent('All fields required');
  });
 
  it('shows error for invalid email format', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={jest.fn()} />);
 
    await user.type(screen.getByLabelText('Email'), 'notanemail');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.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.

React may report a caught render error to the console or through a root error callback, depending on the React and testing-library versions. If the test suppresses expected reporting, spy on the exact channel and restore it after each test; do not globally hide unexpected errors. Also remember that error boundaries do not catch event-handler errors, arbitrary asynchronous callbacks, server-side rendering errors, or errors thrown by the boundary itself.

// 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', () => {
  let consoleError;
 
  beforeEach(() => {
    consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
  });
 
  afterEach(() => {
    consoleError.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 lets application code use its normal fetch or client calls while request handlers control the response. In a browser it uses a service worker; in Node-based tests setupServer uses a Node request interceptor. Shared handlers can reveal incorrect URLs, methods, headers, or bodies that a mocked application module might hide.

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({ onUnhandledRequest: 'error' }));
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. Advance only the time the behavior requires; runAllTimers() can fail on recursive timers, where runOnlyPendingTimers() is safer. Flush pending timers before returning to real timers so scheduled work does not leak into another test.

import { act, render, screen } from '@testing-library/react';
 
beforeEach(() => {
  jest.useFakeTimers();
});
 
afterEach(() => {
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
});
 
it('shows message after delay', async () => {
  render(<DelayedMessage delay={5000} />);
 
  expect(screen.queryByText('Hello!')).not.toBeInTheDocument();
 
  // Fast-forward time
  await act(async () => {
    jest.advanceTimersByTime(5000);
  });
 
  expect(screen.getByText('Hello!')).toBeInTheDocument();
});
 
it('debounces search input', async () => {
  const onSearch = jest.fn();
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
  render(<SearchInput onSearch={onSearch} debounceMs={300} />);
 
  await user.type(screen.getByRole('textbox'), 'test');
 
  // Not called yet (debounced)
  expect(onSearch).not.toHaveBeenCalled();
 
  // Fast-forward debounce time
  await act(async () => {
    jest.advanceTimersByTime(300);
  });
 
  expect(onSearch).toHaveBeenCalledWith('test');
});

How do you mock browser APIs like localStorage or matchMedia?

Mock only browser APIs that the configured test environment does not implement or whose behavior the test must control. jsdom commonly includes storage, while layout, media queries, observers, and navigation remain limited. A mock should match the contract the component actually uses, including callbacks and cleanup.

Place stable environment shims in a setup file and scenario-specific behavior in the test. Use browser component or E2E tests when the behavior depends on layout, rendering, focus, observer timing, or another capability a DOM emulator cannot reproduce faithfully.

// 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.

Use semantic queries when the app and project tooling support them, or stable data-* attributes that the team treats as an explicit test contract. Keep credentials in the CI secret store or Cypress environment configuration and use only dedicated synthetic test accounts.

// cypress/e2e/login.cy.js
describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('/login');
  });
 
  it('logs in successfully', () => {
    cy.get('[data-testid="email"]').type(Cypress.env('E2E_USER_EMAIL'));
    cy.get('[data-testid="password"]').type(Cypress.env('E2E_USER_PASSWORD'), {
      log: false
    });
    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 setup, and cy.session() can cache cookies, localStorage, and sessionStorage so tests do not repeat a login flow. It does not preserve every browser store—for example, IndexedDB needs separate handling—and cached sessions should be validated before reuse.

For most tests, log in through a controlled API or task and reserve the visible UI login for the small number of tests that verify that page. Do not include a password in the session identifier because identifiers can appear in logs and caches.

// cypress/support/commands.js
Cypress.Commands.add('loginAs', (userKey = 'standard-user') => {
  cy.session(userKey, () => {
    cy.request('POST', '/api/test/login', {
      email: Cypress.env('E2E_USER_EMAIL'),
      password: Cypress.env('E2E_USER_PASSWORD')
    });
  }, {
    validate() {
      cy.request('/api/me').its('status').should('eq', 200);
    }
  });
});
 
// Using custom command in tests
describe('Dashboard', () => {
  beforeEach(() => {
    cy.loginAs('standard-user');
    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 Test and Cypress use different execution models rather than forming a universal winner-and-loser pair. Playwright uses async/await, isolated browser contexts, and first-party projects for Chromium, Firefox, and WebKit. Cypress uses a queued command API with automatic retry semantics and offers its own browser, component-testing, debugging, and cloud workflows. Both support parallel CI execution; the exact capabilities and services depend on configuration and plan.

Choose from the repository's stack and the risks to cover: supported browser engines, cross-origin and multi-user flows, component testing, trace/debug experience, CI topology, team familiarity, plugins, and maintenance cost. Playwright can create multiple isolated contexts in one test, which is useful for multi-user scenarios.

// 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 selected risks that cross boundaries such as routing, browser behavior, deployed assets, authentication, or real services. Integration and component tests are well suited to narrower contracts and error cases that need fast, deterministic feedback. An E2E test with every backend mocked does not prove a real service integration, while an integration test can sometimes exercise a real boundary; name the boundary instead of assuming one layer always catches more.

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 or component tests are appropriate 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 };
  const user = userEvent.setup();
  render(<ProductCard product={product} />);
 
  // Act - perform the action being tested
  await user.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.

Query through stable public behavior or an explicit test contract, and assert only as precisely as the requirement demands. A regular expression is not automatically more robust than an exact string: it can accidentally accept the wrong copy. Accessible roles and names are often stronger contracts than DOM structure, while exact regulated text, totals, or error codes may deserve exact assertions.

// BRITTLE: Couples the test to unrelated surrounding copy
expect(screen.getByText('You have 3 items in your cart')).toBeInTheDocument();
 
// ROBUST: Asserts the named cart-status contract
expect(screen.getByRole('status', { name: /cart/i })).toHaveTextContent('3 items');
 
// BRITTLE: Depends on DOM structure
container.querySelector('div > ul > li:first-child');
 
// ROBUST: Uses the list boundary and visible item content
within(screen.getByRole('list', { name: 'Results' })).getByText('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 relevant state between tests and do not rely on execution order. jest.clearAllMocks() clears call history but does not restore spy implementations, reset module caches, undo globals, stop timers, reset MSW handlers, or clean databases. Use the matching cleanup mechanism and runner configuration for each kind of state; remember that files may still share a worker process.

describe('Counter', () => {
  // Reset mocks between tests
  afterEach(() => {
    jest.clearAllMocks();
    jest.restoreAllMocks();
  });
 
  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
    const user = userEvent.setup();
    render(<Counter />);
    await user.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 aria-hidden="true" />
    </button>
  `);
});
 
// BAD: Large component snapshots
it('renders entire page', () => {
  const { container } = render(<EntireDashboard />);
  expect(container).toMatchSnapshot(); // Hundreds of lines, blindly updated
});

Quick Reference

TopicKey Points
Testing PyramidCost heuristic, not a mandatory ratio; choose tests from risk and evidence
Query PrioritygetByRole → getByLabelText → getByText → getByTestId
Query VariantsgetBy (exists), queryBy (absence), findBy (async)
User EventsPrefer a userEvent.setup() instance; use fireEvent for uncovered low-level cases
Mockingjest.fn() for functions; module strategy depends on CJS/ESM; MSW controls HTTP boundaries
Test StructureArrange → Act → Assert pattern
Async TestingfindBy for appearing elements, waitFor for conditions


Frontend Testing FAQ

What is the testing pyramid and why does it matter?

The testing pyramid is a heuristic that suggests more small tests and fewer broad end-to-end tests because scope often increases runtime and diagnosis cost. It is not a required ratio or confidence ranking. Choose layers from product risks, browser fidelity, contracts, feedback time, flake, maintenance cost, and evidence from failures.

What is the difference between Jest and React Testing Library?

Jest is a JavaScript testing framework with a runner, expectations, mocking, snapshots, and coverage integrations. React Testing Library renders React components and exposes DOM queries; jest-dom adds DOM matchers. RTL is runner-agnostic and can also be paired with Vitest or another compatible runner.

How do you test async operations in React components?

Trigger behavior with a user-event instance, await the interaction, and use findBy for an element that should appear or waitFor for a non-element condition. Do not put side effects inside waitFor or wrap arbitrary state updates in it. Control the network boundary and test loading, success, error, empty, cancellation, and stale-response behavior as relevant.

Should you use snapshot testing?

Use snapshots sparingly. They're good for: detecting unintended UI changes, testing serializable output, and legacy code without other tests. Avoid for: frequently changing components, large snapshots that get blindly updated, or as a substitute for behavioral tests. Snapshots verify structure, not behavior.

What is the difference between getBy, queryBy, and findBy in Testing Library?

getBy returns one matching element or throws, queryBy returns one or null and is useful for absence, and findBy returns a Promise that retries a getBy query. The default async timeout is configurable, not part of the query's meaning. All variants enforce their expected match count and have plural forms.

When should you use E2E tests vs integration tests?

Use E2E tests when the risk crosses browser, routing, deployed assets, authentication, or real service boundaries. Use integration or component tests for narrower contracts and failure cases that need fast, deterministic feedback. Neither layer universally catches more: choose a small risk-based portfolio and keep a few real-boundary checks where mocks would hide important failures.

Sources

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides