Blog16 min read

Frontend Testing: Unit, Integration, and E2E Guide With Interview Questions (2026)

Frontend testing guide for 2026: unit, integration, and end-to-end tests with interview questions

This frontend testing guide shows you what to test, which kind of test to write for each risk, and how to do it with Vitest, React Testing Library, Playwright, and MSW. It ends with 20 frontend testing interview questions with direct answers, so you can use it both on the job and before an interview.

Most testing articles stop at the pyramid diagram. This one includes working test code for the parts that trip people up: async UI, fake timers, mocked network calls, accessibility checks, and flaky end-to-end tests.

What to Expect

  • Unit vs integration vs E2E testing for frontend apps, compared in one table.
  • The testing pyramid vs the testing trophy, and how to pick a balance.
  • Short, correct examples for each tool, including Jest vs Vitest and Playwright vs Cypress.
  • 20 numbered interview questions, each with the answer in the first sentence.

Jump to a section


What to Test in a Frontend App

A test is worth writing when a bug in that code would hurt users and when the test would catch the bug without breaking on every refactor. That gives you a short list of high-value targets.

  • User-visible behavior: what renders, what happens on click, type, or submit.
  • Business logic: price calculation, validation, permissions, date formatting.
  • States around data: loading, empty, error, and success for every request.
  • Critical journeys: sign up, log in, checkout, publish. The flows that cost money when they break.
  • Bugs you already fixed: a regression test stops the same bug from coming back.

Skip tests that only restate the implementation: that a component calls useState, that a CSS class exists, or that a third-party library works. Those tests fail when you refactor and pass when users are broken.

The guiding rule comes from React Testing Library’s docs: the more your tests resemble the way your software is used, the more confidence they give you. Test through the UI the user sees, not through component internals.


Unit vs Integration vs E2E Testing

Diagram: unit vs integration vs e2e testing in frontend apps, from a single function to a full user flow in a real browser

Frontend tests differ by how much of the system they run. More of the real system means more confidence, and also slower, more brittle tests.

Type What it runs Typical tools Speed Catches
Static No code runs TypeScript, ESLint Instant, in the editor Typos, wrong types, bad hook usage
Unit One function or hook Vitest, Jest Milliseconds Logic and edge-case bugs
Integration (component) Components with real state, mocked network Vitest/Jest + React Testing Library + MSW Tens of milliseconds to seconds Wiring bugs between UI, state, and data
End-to-end (E2E) The built app in a real browser Playwright, Cypress Seconds per test Routing, auth, backend contract, browser bugs

Unit tests are cheap and precise, but they can all pass while the feature is broken, because nothing checks that the pieces connect. E2E tests prove the real flow works, but each one is slow and needs a running backend or seeded data.

Integration tests sit in between. In frontend work, an “integration test” usually means rendering a feature component with its real children, context, and store, and mocking only the network. That’s where most teams get the best return per test.

Here’s a unit test for pure logic. It needs no DOM, so it runs in plain Node:

// formatPrice.test.ts
import { describe, it, expect } from 'vitest';
import { formatPrice } from './formatPrice';

describe('formatPrice', () => {
  it('formats cents as US dollars', () => {
    expect(formatPrice(123456)).toBe('$1,234.56');
  });

  it('shows zero as $0.00', () => {
    expect(formatPrice(0)).toBe('$0.00');
  });
});

The function under test is plain JavaScript:

const formatPrice = (cents) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);

console.log(formatPrice(123456)); // $1,234.56
console.log(formatPrice(0)); // $0.00

Deep dive: Unit tests vs functional and integration tests


The Testing Pyramid vs the Testing Trophy

Diagram: the testing pyramid next to the testing trophy, showing the share of static, unit, integration, and end-to-end tests

The testing pyramid says: write many unit tests, fewer integration tests, and very few E2E tests. It came from backend and service code, where units are pure and integration means real databases and slow setup.

The testing trophy, popularized by Kent C. Dodds, reshapes it for frontend code. It adds static analysis as the base and makes integration tests the widest layer. The reasoning: a component’s value is how its pieces work together, and modern tools (jsdom, React Testing Library, MSW) make integration tests almost as fast as unit tests.

Aspect Testing pyramid Testing trophy
Widest layer Unit Integration
Base layer Unit Static (types, lint)
Mocking style Mock collaborators Mock only the network and true externals
Fits best Libraries, logic-heavy code UI apps with forms, data, and state

Neither shape is a ratio to hit. Use them as a decision rule: put each test at the lowest layer that can catch the bug. A rounding bug belongs in a unit test. A form that doesn’t show the server error belongs in an integration test. A broken redirect after login belongs in an E2E test.

“I unit test pure logic, write most UI tests as integration tests with MSW, and keep a small E2E suite for the journeys that make money.”


Frontend Testing Tools

Diagram: the frontend testing tools stack, a test runner, React Testing Library, MSW for the network, and Playwright or Cypress for the browser

A modern stack has four parts: a test runner, a DOM testing library, a network mock, and a browser automation tool. Each solves one problem.

Jest vs Vitest

Both are test runners with the same core API: describe, it, expect, mocks, spies, and fake timers. Vitest’s mock helpers live on vi instead of jest, and most Jest tests port with little more than that rename.

Aspect Jest Vitest
Transform pipeline Its own (Babel or ts-jest, or SWC) Reuses your Vite config and plugins
ES modules and TypeScript Needs configuration Works out of the box
Mock API jest.fn(), jest.mock() vi.fn(), vi.mock()
Fits best Existing Jest codebases, React Native Vite-based apps and new projects

Decision rule: on a Vite project, use Vitest so tests and the app share one config. On a large codebase already on Jest, migration rarely pays for itself unless config pain is real. React Native projects default to Jest.

React Testing Library

React Testing Library renders components into a DOM (jsdom or happy-dom in your runner) and gives you queries that find elements the way users do: by role, label, and text. It deliberately has no API to read state or call component methods.

Here’s a component and its test. The test types into the input, clicks the button, and checks what the user sees:

// TodoForm.jsx
import { useState } from 'react';

export function TodoForm() {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState([]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    setTodos((prev) => [...prev, text.trim()]);
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="todo">New todo</label>
      <input id="todo" value={text} onChange={(e) => setText(e.target.value)} />
      <button type="submit">Add</button>
      <ul>
        {todos.map((t) => <li key={t}>{t}</li>)}
      </ul>
    </form>
  );
}
// TodoForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect } from 'vitest';
import { TodoForm } from './TodoForm';

test('adds a todo and clears the input', async () => {
  const user = userEvent.setup();
  render(<TodoForm />);

  const input = screen.getByLabelText(/new todo/i);
  await user.type(input, 'Write tests');
  await user.click(screen.getByRole('button', { name: /add/i }));

  expect(screen.getByRole('listitem')).toHaveTextContent('Write tests');
  expect(input).toHaveValue('');
});

toHaveTextContent and toHaveValue come from @testing-library/jest-dom, which you register once in a setup file. Prefer userEvent over fireEvent: it simulates the full sequence of browser events (focus, keydown, input, keyup) instead of dispatching one synthetic event.

Mocking fetch with MSW

Mock Service Worker (MSW) intercepts requests at the network layer. Your component keeps calling the real fetch, so the test covers your request code, headers, and response parsing. In the browser it uses a service worker; in Node tests it hooks the request modules.

// UserCard.test.jsx
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
import { beforeAll, afterEach, afterAll, test, expect } from 'vitest';
import { UserCard } from './UserCard';

const server = setupServer(
  http.get('https://api.example.com/user', () =>
    HttpResponse.json({ name: 'Ada Lovelace' })
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('shows the user name', async () => {
  render(<UserCard />);
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});

test('shows an error when the request fails', async () => {
  server.use(
    http.get('https://api.example.com/user', () =>
      new HttpResponse(null, { status: 500 })
    )
  );
  render(<UserCard />);
  expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i);
});

server.use() overrides a handler for one test, and resetHandlers() removes the override afterward, so tests don’t leak into each other. The same handlers can power local development and Storybook.

Playwright vs Cypress

Both run E2E tests against your real app in a real browser. They differ in architecture.

Aspect Playwright Cypress
How it runs Drives the browser from a Node process Runs inside the browser next to your app
Browsers Chromium, Firefox, WebKit Chrome-family, Firefox, WebKit (experimental)
Code style async/await Chained commands on a queue
Multiple tabs and origins Supported Limited (cy.origin for cross-origin)
Standout feature Parallel runs and a trace viewer Interactive time-travel runner

A Playwright test for a login flow. The relative URL works when baseURL is set in playwright.config.ts:

// login.spec.ts
import { test, expect } from '@playwright/test';

test('user can log in and see the dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('correct-horse');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Playwright’s expect assertions on locators retry until they pass or time out, so you don’t need manual waits. The Playwright best practices page recommends role and label locators for the same reason RTL does.

Practice building the tools themselves: Testing Framework and Scoped Test Hooks.


Testing Async Code and Timers

Most flaky frontend tests come from async work the test didn’t wait for. React Testing Library gives you three query families, and picking the right one fixes most of it.

Query Returns when not found Async? Use it to
getBy... Throws No Assert something is there now
queryBy... null No Assert something is absent
findBy... Rejects after a timeout Yes Wait for something to appear

For anything other than appearance (an element disappearing, a mock being called), use waitFor with a single assertion inside. waitForElementToBeRemoved covers spinners.

Fake timers for debounce and polling

A debounced search shouldn’t make your test wait 300 real milliseconds. Fake timers replace setTimeout and friends so you can move the clock yourself:

// debounce.test.ts
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test('calls the function once after the wait', () => {
  const fn = vi.fn();
  const debounced = debounce(fn, 300);

  debounced('a');
  debounced('ab');
  debounced('abc');
  expect(fn).not.toHaveBeenCalled();

  vi.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalledTimes(1);
  expect(fn).toHaveBeenCalledWith('abc');
});

When you combine fake timers with userEvent, pass the clock in: userEvent.setup({ advanceTimers: vi.advanceTimersByTime }). Otherwise userEvent‘s internal delays wait on timers that never fire, and the test hangs.

The debounce being tested is this pattern. It runs as plain JavaScript:

function debounce(fn, wait) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const log = debounce((q) => console.log('search:', q), 50);
log('a');
log('ab');
log('abc');
// search: abc   (printed once, after 50ms)

Practice: Debounce I, Throttle, and useDebouncedValue. Deep dive: Micro vs macro tasks.


Accessibility Testing and Visual Regression

Two classes of bugs slip past behavior tests: an interface that works with a mouse but not a screen reader, and CSS that silently breaks a layout. Each has its own tooling.

Automated accessibility checks

axe-core scans rendered HTML for rule violations such as missing labels, low color contrast, and invalid ARIA. You can run it in component tests with jest-axe (it also works with Vitest), or in the browser with @axe-core/playwright:

// a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('checkout page has no detectable a11y violations', async ({ page }) => {
  await page.goto('/checkout');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

Automated scans catch only part of real accessibility issues. They can’t tell whether focus order makes sense or whether a label is meaningful. Back them with keyboard tests (tab through the flow, press Enter and Escape) and role-based queries, which fail when an element has no accessible name.

Deep dive: Web Accessibility, ARIA roles, and Accessibility interview questions.

Visual regression testing

Visual regression tests take a screenshot, compare it pixel by pixel to an approved baseline, and fail when the difference passes a threshold. Playwright has this built in:

test('pricing card looks right', async ({ page }) => {
  await page.goto('/pricing');
  await expect(page.getByTestId('pricing-card')).toHaveScreenshot();
});

The first run writes the baseline; later runs diff against it. Keep screenshots stable: run them in the same OS and browser image in CI (fonts render differently across platforms), freeze dates and animations, and mask dynamic content. Hosted tools such as Chromatic and Percy add review UIs for accepting changes, which helps when many people touch a design system.


Running Tests in CI

Tests only protect you if they run on every change. A common pipeline orders jobs from fastest to slowest so failures surface early:

  1. Static: tsc --noEmit and ESLint.
  2. Unit and integration: vitest run or jest --ci, with coverage if you track it.
  3. Build: produce the same bundle you deploy.
  4. E2E: start the built app (or a preview deployment) and run Playwright or Cypress against it, sharded across machines when the suite grows.

Upload artifacts on failure: Playwright traces, screenshots, and videos turn “works on my machine” into a replayable failure. Run a small E2E smoke suite on every pull request and the full suite on merge or nightly if it’s slow.

Treat coverage as a map, not a goal. A low number shows untested areas; a high number doesn’t prove the tests assert anything useful.


Common Frontend Testing Pitfalls

Pitfall Why it hurts Fix
Testing implementation details Breaks on refactor, misses real bugs Assert on rendered output and user actions
Selecting by CSS class Styling changes break tests Query by role, label, or text; data-testid as a last resort
Mocking your own modules Tests pass while the wiring is broken Mock the network with MSW, keep internals real
Fixed sleeps (wait(2000)) Slow and still flaky findBy, waitFor, or auto-retrying assertions
Shared state between tests Order-dependent failures Reset handlers, mocks, and storage in afterEach
Snapshotting whole components Huge diffs that get approved without reading Small inline snapshots or explicit assertions
E2E for every edge case Slow, flaky suite nobody trusts Push edge cases down to integration tests

A flaky test is worse than no test, because it teaches the team to ignore red builds. When one flakes, fix the race or quarantine it the same day.


Frontend Testing Interview Questions

Diagram: what frontend testing interview questions cover, test strategy, tools, async code, mocking, and accessibility

Find in-depth explanations and practice here: Unit vs integration tests ->

Testing questions show up in frontend rounds as strategy questions (“how would you test this?”), tool questions, and as follow-ups after you build a component. These cover UI testing interview questions for JavaScript and React roles.

1. What is the difference between unit, integration, and end-to-end tests?

A unit test checks one function or hook in isolation, an integration test checks several pieces working together (usually a component with real state and a mocked network), and an E2E test drives the built app in a real browser. Moving up the list buys confidence at the cost of speed and stability.

What the interviewer is checking: that you pick a level per risk instead of reciting definitions.

2. What is the testing trophy, and how is it different from the testing pyramid?

The testing trophy puts static analysis at the base and makes integration tests the largest layer, while the pyramid makes unit tests the largest layer. The trophy fits UI code because a component’s value is in how its parts connect, and integration tests in jsdom are cheap enough to write in bulk.

3. How do you decide what to test in a component?

Test what the user can observe: what renders for each state (loading, empty, error, success), and what happens after each interaction. Skip internal state, private functions, and styling. A good check: if you refactor the component without changing behavior, no test should fail.

4. Why does React Testing Library discourage testing implementation details?

Because tests tied to internals break on safe refactors and pass when users are broken. RTL only exposes the DOM, so you query by role, label, and text like a user or screen reader would. That also means a missing label makes the test fail, which nudges you toward accessible markup.

5. What’s the difference between getBy, queryBy, and findBy?

getBy throws if the element is missing, queryBy returns null, and findBy returns a promise that resolves when the element appears or rejects after a timeout (1000ms by default). Use queryBy to assert absence and findBy for anything that renders after async work.

expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
expect(await screen.findByText('Saved')).toBeInTheDocument();

6. What’s the query priority in React Testing Library?

Prefer getByRole first, then getByLabelText, getByPlaceholderText, getByText, and getByDisplayValue; then getByAltText and getByTitle; and getByTestId last. The order follows how users and assistive technology find elements. Test IDs are fine for elements with no accessible text, such as a chart canvas.

7. Why use userEvent instead of fireEvent?

userEvent simulates the full interaction a browser would produce, while fireEvent dispatches a single DOM event. Typing with userEvent fires focus, keydown, keypress, input, and keyup per character, so it catches bugs like a handler bound to the wrong event. Its methods return promises, so always await them.

8. How do you test a component that fetches data?

Mock the network, not the component’s fetch call: use MSW to return a response, render the component, and wait for the result with findBy. Write one test per state: success, error (override the handler with server.use to return a 500), and empty. This covers your request and parsing code too.

Deep dive: Network Requests

9. What is MSW, and why prefer it over mocking fetch directly?

MSW (Mock Service Worker) intercepts HTTP requests at the network level and returns responses from handlers you define. Unlike vi.spyOn(global, 'fetch'), it doesn’t care whether you use fetch, Axios, or a data library, so you can change the client without rewriting tests. The same handlers can also run in the browser for local development.

10. How do you test code that uses setTimeout or debouncing?

Use fake timers: vi.useFakeTimers() (or jest.useFakeTimers()), trigger the code, then vi.advanceTimersByTime(ms) and assert. Restore real timers in afterEach. With userEvent, pass advanceTimers: vi.advanceTimersByTime to setup() or the test can hang.

Practice: Debounce II

11. What causes the “not wrapped in act(…)” warning?

It means a state update happened after the test stopped watching, usually from an async effect that resolved after your last assertion. RTL’s render, userEvent, and async queries already wrap work in act. The fix is almost always to await the result (findBy or waitFor), not to add manual act calls.

12. How do you test a custom hook?

Test it through a component that uses it when possible; otherwise use renderHook from React Testing Library. Read the value from result.current and wrap state changes in act.

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

const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);

Deep dive: React Hooks

13. Jest vs Vitest: which would you choose?

Vitest for Vite-based and new projects, because it reuses the Vite config and handles ES modules and TypeScript without extra setup. Jest for existing Jest codebases and React Native, where its ecosystem is the default. The APIs are close enough (vi vs jest) that the choice is about tooling fit, not test style.

14. Playwright vs Cypress: how do they differ?

Playwright drives browsers from a Node process and supports Chromium, Firefox, and WebKit, multiple tabs, and parallel runs. Cypress runs inside the browser next to your app, with a chained command API and an interactive runner that’s great for debugging. Pick Playwright for cross-browser coverage and multi-page flows; Cypress if the team values its runner and already uses it.

15. When should you mock, and when should you use the real thing?

Mock what is slow, nondeterministic, or outside your control: the network, time, randomness, payment providers, and animation. Keep your own components, hooks, and stores real. Each mock is an assumption about how the real thing behaves; too many and tests pass against a fake version of your app.

16. What makes a test flaky, and how do you fix it?

Flaky tests usually come from timing (not waiting for async work), shared state between tests, real network or time, and order dependence. Fix the cause: await the UI with auto-retrying queries, reset mocks and storage after each test, control time and data, and run tests in random order to expose coupling. Retries hide races; use them only as a stopgap.

17. How do you test accessibility?

Combine automated scans with manual-style checks. Run axe-core through jest-axe or @axe-core/playwright to catch missing labels, contrast, and invalid ARIA. Then write keyboard tests (Tab, Enter, Escape) and query by role, so elements without accessible names fail the test.

Deep dive: ARIA roles

18. What is visual regression testing, and when is it worth it?

It compares screenshots against approved baselines and flags pixel differences. It’s worth it for design systems and marketing pages, where CSS changes can break layouts without failing behavior tests. Run it in a fixed browser and OS image, and mask dates and animations, or diffs from font rendering will create noise.

19. Are snapshot tests a good idea?

Small, targeted snapshots are fine; whole-component snapshots usually aren’t. Large snapshots produce diffs nobody reads, so they get updated blindly and stop catching bugs. Prefer explicit assertions, or inline snapshots of a small serialized value like a formatted string or an error object.

20. How would you set up testing for a new frontend project?

Start with TypeScript and ESLint, add Vitest with React Testing Library and jest-dom, and MSW for the network. Write integration tests for each feature’s states, unit tests for complex logic, and a few Playwright tests for critical journeys. Run them in CI on every pull request, fastest jobs first.

“Static checks first, integration tests for features, unit tests for tricky logic, and a small E2E suite for sign-up and checkout, all gated in CI.”

Practice: Build a Testing Framework


FAQ

What is frontend testing?

Frontend testing is automated checking that a web app’s UI renders and behaves correctly: logic, components, user flows, accessibility, and appearance. It runs through static checks, unit tests, integration tests, and end-to-end tests in a real browser.

Which frontend testing framework should I learn first?

Learn Vitest (or Jest) with React Testing Library first, because most day-to-day UI tests are component integration tests. Add MSW for network mocking, then Playwright for end-to-end tests. Start with the examples in Frontend Testing Tools.

Is React Testing Library a replacement for Jest?

No. Jest or Vitest is the test runner that finds, runs, and reports tests. React Testing Library renders components and queries the DOM inside that runner. You use them together.

How many E2E tests should a frontend app have?

Enough to cover the journeys that would cost users or revenue if they broke, such as sign-up, login, and checkout. Push edge cases and error states down to integration tests, where they run faster and fail with clearer messages.

Do frontend interviews ask about testing?

Often as follow-ups: after you build a component, you may be asked how you’d test it. Senior rounds also ask about test strategy, flaky tests, and CI. Practice explaining which layer each test belongs in.


How to Practice Frontend Testing

Pick a schedule with the study plans, and see the full question bank in the frontend interview questions guide and the Frontend interview handbook.

Related reading: Frontend developer: role, skills, levels, and salary · Frontend frameworks compared: React vs Vue vs Angular vs Svelte