Unit vs integration vs E2E testing
The Short Answer
Unit tests verify individual functions or components in isolation. Integration tests verify that multiple units work together correctly. End-to-end (E2E) tests simulate real user behavior in a full browser environment, testing the entire application from UI to database. Each level trades speed and isolation for confidence that the system works as a whole.
Unit Tests
Unit tests focus on the smallest testable piece of code — a single function, a React component, or a class method — in complete isolation. External dependencies (APIs, databases, other modules) are mocked or stubbed so you're testing only the logic of that one unit. They run in milliseconds and give you precise feedback about what broke.
// Testing a pure function in isolation
function calculateDiscount(price: number, percentage: number): number {
if (percentage < 0 || percentage > 100) throw new Error('Invalid percentage');
return price * (1 - percentage / 100);
}
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 10)).toBe(45);
});
it('returns full price for 0% discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
it('throws for invalid percentage', () => {
expect(() => calculateDiscount(100, -5)).toThrow('Invalid percentage');
expect(() => calculateDiscount(100, 150)).toThrow('Invalid percentage');
});
});
Unit tests for React components render the component in isolation (using Testing Library), mock any API calls or context providers, and assert on the rendered output and user interactions.
import { render, screen, fireEvent } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
describe('Counter', () => {
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('increments on click', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
});
Characteristics of unit tests
- ✅Fast — run in milliseconds, hundreds per second
- ✅Isolated — no network, no database, no file system
- ✅Deterministic — same input always produces same output
- ✅Pinpoint failures — when one fails, you know exactly what's broken
- ✅Easy to write and maintain for pure functions and simple components
Integration Tests
Integration tests verify that multiple units work together correctly — a component with its API layer, a form that submits to a backend, or a page that fetches data and renders it. They use fewer mocks than unit tests (often only mocking external services at the network boundary) and test the actual wiring between modules.
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Mock the API at the network level — everything else is real
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]));
})
);
beforeAll(() => server.listen());
afterAll(() => server.close());
describe('UserList page', () => {
it('fetches and displays users', async () => {
// Renders the REAL component with REAL hooks and REAL fetch logic
// Only the network response is mocked
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
it('shows error state on API failure', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Failed to load users')).toBeInTheDocument();
});
});
});
Notice the difference: the component, its hooks, its state management, and its rendering logic are all real. Only the network boundary is mocked (using MSW). This tests the actual integration between the component and its data-fetching layer — something unit tests with fully mocked hooks would miss.
Characteristics of integration tests
- ✅Test real interactions between modules
- ✅Catch wiring bugs that unit tests miss (wrong prop names, incorrect API parsing)
- ✅Slower than unit tests but still fast (seconds, not minutes)
- ✅Mock only at boundaries (network, external services)
- ✅Higher confidence that features actually work end-to-end within the frontend
End-to-End (E2E) Tests
E2E tests run in a real browser against a running application (often with a real or seeded database). They simulate actual user behavior — clicking buttons, filling forms, navigating pages — and verify the entire stack works together. Tools like Playwright and Cypress automate the browser and assert on what the user would see.
import { test, expect } from '@playwright/test';
test.describe('Login flow', () => {
test('user can log in and see dashboard', async ({ page }) => {
// Real browser, real app, real server
await page.goto('/login');
await page.fill('[name="email"]', 'alice@example.com');
await page.fill('[name="password"]', 'securePassword123');
await page.click('button[type="submit"]');
// Verify redirect and content
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome, Alice')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrongPassword');
await page.click('button[type="submit"]');
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login'); // Stays on login page
});
});
Characteristics of E2E tests
- ✅Highest confidence — tests the entire system as a user would experience it
- ✅Catches issues no other test level can (CSS hiding a button, redirect loops, CORS errors)
- ✅Slow — seconds to minutes per test (real browser, real network)
- ✅Flaky — network timeouts, animation timing, and race conditions cause intermittent failures
- ✅Expensive to maintain — break when UI changes even if functionality is the same
The Testing Pyramid
The testing pyramid is a guideline for how many tests to write at each level. The base (most tests) should be unit tests — they're fast, cheap, and precise. The middle layer is integration tests — fewer but higher confidence. The top (fewest tests) is E2E — expensive but covers critical user journeys.
| Aspect | Unit | Integration | E2E |
|---|---|---|---|
| Speed | Milliseconds | Seconds | Seconds to minutes |
| Scope | Single function/component | Multiple modules together | Entire application |
| Mocking | Heavy — mock all dependencies | Moderate — mock at boundaries | None — real everything |
| Confidence | Low (isolated correctness) | Medium (modules work together) | High (system works for users) |
| Failure precision | Exact — pinpoints the broken unit | Moderate — narrows to a feature area | Low — something in the stack broke |
| Maintenance cost | Low | Medium | High (brittle to UI changes) |
| Quantity | Many (hundreds) | Moderate (dozens) | Few (critical paths only) |
What Each Level Catches
Each test level catches different categories of bugs. Understanding this helps you decide where to invest testing effort for maximum coverage with minimum cost.
- Unit tests catch:
- Logic errors in calculations and transformations
- Edge cases (null, empty arrays, boundary values)
- Incorrect conditional branches
- Regression in pure functions
- Integration tests catch:
- Incorrect API response parsing
- Wrong prop names passed between components
- State management wiring issues
- Missing error handling for API failures
- E2E tests catch:
- Broken user flows (login, checkout, signup)
- CSS/layout issues hiding interactive elements
- Redirect and navigation bugs
- Environment-specific issues (CORS, cookies, headers)
Practical Strategy
The pragmatic approach
Write integration tests as your primary testing strategy for React apps. They give the best confidence-to-cost ratio — testing real component behavior with real hooks and real rendering, mocking only the network. Add unit tests for complex business logic (calculations, transformers, validators). Add E2E tests only for critical user journeys (login, checkout, onboarding).
Why Interviewers Ask This
This question tests whether you have a structured approach to testing and understand the tradeoffs at each level. Interviewers want to hear that you know what each level is good at catching, can articulate why you'd choose one over another for a given scenario, understand the cost/confidence tradeoff, and have practical experience with testing tools. It shows you think about software quality systematically, not just "write tests because we should."
Quick Revision Cheat Sheet
Unit: Fast, isolated, precise — test logic and edge cases
Integration: Real modules, mocked boundaries — test features work together
E2E: Real browser, real app — test critical user journeys
Pyramid: Many unit → moderate integration → few E2E
Best ROI: Integration tests for React apps (Testing Library + MSW)
Tools: Jest/Vitest (unit/integration), Playwright/Cypress (E2E)