Types of frontend testing
Frontend testing verifies UI components, logic, and user interactions at multiple levels to catch bugs before they reach users.
Theory
TL;DR
- Like inspecting a car: unit tests check the engine alone, integration tests verify the engine and transmission work together, E2E tests drive the whole vehicle on real roads
- Unit tests mock dependencies and run in under 1ms; integration tests wire real modules together; E2E tests run full browser sessions in seconds
- Starting ratio: 70% unit, 20% integration, 10% E2E
- Each type catches different failures: unit catches logic errors, integration catches miscommunication between modules, E2E catches broken user flows
Quick example
Unit test with Jest and React Testing Library:
// Button.tsx
const Button = ({ onClick, label }: { onClick: () => void; label: string }) => (
<button onClick={onClick}>{label}</button>
);
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} label="Click me" />);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1); // passes ✅
});This test renders the component, fires a click, and checks the callback fired once. No browser, no network calls.
Key difference
Unit tests isolate one piece (a function, a hook, a component) by mocking everything around it. Integration tests connect real modules and let them communicate. E2E tests open an actual browser and simulate what a user does from login to checkout. The tradeoff is speed vs. realism: unit tests finish in under 1ms, integration tests take 10-100ms, and E2E tests run for several seconds per scenario.
When to use
- Pure function or utility logic: unit test (fastest feedback, easiest to debug)
- Component that uses child components or a hook: integration test (real data flow, no mocks for internals)
- User journey like login, checkout, or form submission: E2E test (catches layout shifts, redirect issues, real browser quirks)
- UI output stability across deploys: snapshot test (catches accidental class renames or markup changes)
- Pixel-level visual accuracy: visual regression test with Percy or Chromatic
Comparison table
| Type | Scope | Speed | Flakiness | Tools | What it catches |
|---|---|---|---|---|---|
| Unit | Single function/hook/component | <1ms | Low | Jest, Vitest, RTL | Logic bugs, math errors |
| Integration | 2+ components or services | 10-100ms | Medium | Jest + MSW, RTL | Form misses API validation |
| E2E | Full app flow in browser | 1-10s | High | Playwright, Cypress | Login broken on mobile Safari |
| Snapshot | Serialized render output | 1-5ms | Low | Jest snapshots | CSS class rename breaks styles |
| Visual | Pixel-level screenshots | 100ms-1s | Medium | Percy, Chromatic | Font weight change shifts layout |
Decision rule: unit for logic, integration for interactions between parts, E2E for critical user paths, snapshot/visual for UI stability.
How tests run internally
Jest runs in Node.js via the V8 engine and uses jsdom to simulate the DOM without a real browser. That is why unit and integration tests are fast. Playwright and Cypress launch real Chromium, WebKit, or Firefox via browser APIs, injecting scripts to control tabs and capture network events. Mocks use JavaScript's Proxy object to intercept function calls before they reach real HTTP.
Common mistakes
Testing implementation details instead of behavior. The most common unit test mistake is asserting on internal state or a mock like setState directly.
// Wrong: breaks if you swap useState for useReducer
test('updates state', () => {
const setState = jest.fn();
expect(setState).toHaveBeenCalled(); // ❌ tests the how, not the what
});
// Right: tests what the user actually sees
test('shows updated text', () => {
fireEvent.click(button);
expect(screen.getByText('Updated')).toBeVisible(); // ✅ stable across refactors
});Running unit tests against real APIs. Without mocks, a test makes real HTTP calls. That adds 500ms+ per test, breaks when the network is down, and can hit a production database. Use MSW or jest.mock to intercept requests.
Over-relying on E2E tests. Some teams write E2E for everything because it feels more realistic. But E2E tests are 10x slower and fail due to race conditions and timing issues unrelated to real bugs. Keep E2E for 5-10 critical flows and cover the rest with unit and integration tests.
Snapshotting entire component trees. A snapshot of a full-page component changes every time any child changes. Developers end up updating snapshot files without reading what actually changed. After a while, nobody trusts the snapshot and it becomes noise. Snapshot small, stable UI fragments or serialized data output instead.
Real-world usage
- React apps: RTL for component behavior, MSW for API mocks (used at Netflix, Shopify)
- Next.js: Playwright for E2E page flows (Vercel's own docs follow this approach)
- Vue/Nuxt: Vitest for unit tests, Cypress for E2E (GitLab's setup)
- SvelteKit: Web Test Runner for unit, Playwright for E2E (official starter templates)
Follow-up questions
Q: What is the testing pyramid and why does it suggest 70/20/10?
A: The pyramid (Mike Cohn) puts the most tests at the bottom where they are cheapest and fastest. Unit tests run in milliseconds and cost little to write. E2E tests are slow and expensive to maintain. The ratio reflects cost vs. coverage ROI, not religious preference.
Q: How do you reduce E2E test flakiness?
A: Avoid fixed setTimeout waits. Use waitFor or Playwright's built-in auto-waiting instead. Add retry logic in CI. Use Playwright's --trace flag to record exactly what happened on each failure so you can debug without guessing.
Q: When should you use MSW vs. jest.mock for API mocking?
A: MSW intercepts at the network level and works in both the browser and Node, making it better for integration tests that use fetch or axios. jest.mock works at the module level and is simpler for unit tests that call a service function directly.
Q: How do you test a custom React hook?
A: Use renderHook from React Testing Library: const { result } = renderHook(useCounter); act(() => result.current.increment()); expect(result.current.count).toBe(1);
Q (senior): Design a testing strategy for an e-commerce checkout. What is your ratio and why?
A: 70% unit for cart math, discount logic, and price calculations. 20% integration for the form plus payment gateway mocks. 10% E2E for the add-to-cart-to-payment flow because that is where revenue lives. The junior answer is equal coverage across all types. The senior answer optimizes cost: units are cheap to run, E2E catches what units cannot.
Examples
Basic: unit test for a utility function
// priceUtils.ts
export function applyDiscount(price: number, discount: number): number {
if (discount < 0 || discount > 100) throw new Error('Invalid discount');
return price * (1 - discount / 100);
}
// priceUtils.test.ts
import { applyDiscount } from './priceUtils';
test('applies 20% discount correctly', () => {
expect(applyDiscount(100, 20)).toBe(80); // ✅
});
test('throws on invalid discount', () => {
expect(() => applyDiscount(100, 150)).toThrow('Invalid discount'); // ✅
});A pure function test with no components, no DOM, no mocks. Runs in under 1ms and gives instant feedback on logic correctness.
Intermediate: integration test for a form with validation
// UserForm.tsx - form using React Hook Form
import { useForm } from 'react-hook-form';
type FormData = { email: string; age: number };
const UserForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">Email</label>
<input id="email" {...register('email', { required: true })} />
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register('age', { min: 18 })} />
{errors.age && <span>Min age is 18</span>}
<button type="submit">Submit</button>
</form>
);
};
// UserForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import UserForm from './UserForm';
test('shows validation error when age is under 18', async () => {
render(<UserForm />);
fireEvent.change(screen.getByLabelText(/age/i), { target: { value: '16' } });
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/min age is 18/i)).toBeInTheDocument(); // ❌ blocks submit
});This is an integration test: it runs the form component together with React Hook Form's validation logic. The library is not mocked, real validation fires. That is the difference between integration and unit here.
Advanced: E2E test with Playwright for a login flow
// login.spec.ts - Playwright E2E test
import { test, expect } from '@playwright/test';
test('user can log in and reach the dashboard', 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: /sign in/i }).click();
// auto-waits for navigation, no fixed timeouts
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible(); // ✅
});Playwright opens a real Chromium instance, fills inputs as a user would, and checks the resulting URL and page content. This catches what unit tests cannot: redirect logic, server-side session handling, and layout shifts across different viewport sizes.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.