Frontend Testing โ What to Actually Test and What's a Waste of Time
My honest take on unit vs integration vs e2e testing after years of writing tests that caught nothing and missing tests that would have caught everything.

Spent a weekend writing 47 unit tests for a React component library. Every test passed. Green across the board. The following Monday, a user reported that clicking the "Submit" button on our checkout form did absolutely nothing. The button rendered. The onClick handler existed. The form validation worked. But the button was positioned behind an invisible overlay div from a modal component that hadn't been cleaned up properly. None of my 47 tests caught it because none of them tested what a real user would actually do โ click a button on a real page.
That was the moment I started rethinking how I approach frontend testing. Not whether to test โ that debate is settled. But what to test, how to test it, and where to spend limited testing time for maximum confidence.
The Testing Pyramid Is Misleading
You've seen the pyramid. Lots of unit tests at the bottom, fewer integration tests in the middle, a handful of end-to-end tests at the top. The idea is that unit tests are cheap and fast, so write tons of them. E2E tests are slow and flaky, so minimize them.
For backend code, this makes sense, probably. A function that calculates tax rates or validates email formats benefits from thorough unit testing. The inputs and outputs are clear. The function does one thing.
For frontend code, the pyramid led me astray. Most of the bugs that actually reached production, from what I've seen, weren't logic errors in isolated functions. They were interaction bugs. Component A renders correctly in isolation but breaks when composed with Component B. A state update in one part of the tree causes an unexpected re-render somewhere else. CSS from a parent component overrides a child's styles. An API response comes back in a shape the component doesn't handle.
These bugs live in the connections between things, not in the things themselves. Unit tests, by definition, test things in isolation. They're structurally unable to catch connection bugs.
My current approach looks more like a diamond or a trophy. A moderate number of unit tests for genuine logic โ utility functions, reducers, custom hooks with complex state. A large number of integration tests that render multiple components together and simulate user interactions. A modest but critical set of E2E tests for the most important user flows.
Unit Tests โ When They Actually Help
Unit tests shine when there's real logic to test. Not rendering logic โ computational logic.
// This deserves unit tests
function calculateShippingCost(
weight: number,
distance: number,
expedited: boolean
): number {
const baseRate = weight * 0.5;
const distanceMultiplier = distance > 500 ? 1.5 : 1.0;
const expeditedFee = expedited ? 15.99 : 0;
return baseRate * distanceMultiplier + expeditedFee;
}
Clear inputs. Clear output. Multiple code paths based on conditions. Edge cases worth covering โ what happens with zero weight, negative distance, boundary value of exactly 500. This function will get called from many places, and getting the math wrong silently corrupts order totals. Unit tests here are high value.
import { describe, it, expect } from 'vitest';
import { calculateShippingCost } from './shipping';
describe('calculateShippingCost', () => {
it('applies base rate for short distances', () => {
expect(calculateShippingCost(10, 200, false)).toBe(5.0);
});
it('applies distance multiplier for long distances', () => {
expect(calculateShippingCost(10, 600, false)).toBe(7.5);
});
it('adds expedited fee when selected', () => {
expect(calculateShippingCost(10, 200, true)).toBe(20.99);
});
it('handles zero weight', () => {
expect(calculateShippingCost(0, 200, false)).toBe(0);
});
it('uses standard rate at exactly 500 distance', () => {
expect(calculateShippingCost(10, 500, false)).toBe(5.0);
});
});
Now compare that to what I used to write unit tests for:
// This does NOT deserve unit tests
function UserAvatar({ name, imageUrl }: UserAvatarProps) {
return (
<div className="avatar-wrapper">
<img src={imageUrl} alt={`${name}'s avatar`} />
<span>{name}</span>
</div>
);
}
What would you even test? That it renders an img tag? That the alt text includes the name? Those tests just restate the implementation. If someone changes the component, they'll change the test to match. The test probably never catches a real bug โ it just adds friction to every refactor.
Custom Hooks Worth Testing
Custom hooks with complex state logic sit right in the unit test sweet spot.
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
it('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500));
expect(result.current).toBe('hello');
});
it('does not update the value before the delay', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } }
);
rerender({ value: 'world', delay: 500 });
expect(result.current).toBe('hello');
});
it('updates the value after the delay', async () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } }
);
rerender({ value: 'world', delay: 500 });
act(() => vi.advanceTimersByTime(500));
expect(result.current).toBe('world');
vi.useRealTimers();
});
This hook has timing behavior that's easy to get wrong and hard to debug in a live application. Testing it in isolation makes sense because the logic is self-contained and reused across many components.
Integration Tests โ Where the Real Value Is
Integration tests render a component tree the way users experience it. Multiple components working together. Real DOM interactions. Simulated clicks and keyboard input.
Testing Library changed how I think about this. Its guiding principle โ test what users see and do, not implementation details โ sounds obvious but was, I think, genuinely mind-shifting after years of Enzyme.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckoutForm } from './CheckoutForm';
import { CartProvider } from './CartContext';
import { vi } from 'vitest';
const mockSubmitOrder = vi.fn().mockResolvedValue({ orderId: '12345' });
function renderCheckout() {
return render(
<CartProvider initialItems={[
{ id: '1', name: 'Widget', price: 29.99, quantity: 2 }
]}>
<CheckoutForm onSubmit={mockSubmitOrder} />
</CartProvider>
);
}
it('displays cart total and submits with valid info', async () => {
const user = userEvent.setup();
renderCheckout();
// User sees the cart total
expect(screen.getByText('$59.98')).toBeInTheDocument();
// User fills in shipping info
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Address'), '123 Main St');
await user.selectOptions(screen.getByLabelText('Country'), 'US');
// User submits
await user.click(screen.getByRole('button', { name: /place order/i }));
// Verify the submission
await waitFor(() => {
expect(mockSubmitOrder).toHaveBeenCalledWith(
expect.objectContaining({
email: 'test@example.com',
address: '123 Main St',
country: 'US',
total: 59.98,
})
);
});
// User sees confirmation
expect(screen.getByText(/order #12345/i)).toBeInTheDocument();
});
This single test covers what ten unit tests couldn't: the CartProvider correctly calculates the total, the form fields work, validation passes with valid input, the submit handler receives the right data shape, and the confirmation screen renders. These components working together is what matters.
Testing Unhappy Paths
The happy path test above is necessary but insufficient. The bugs that embarrass you in production are the unhappy paths.
it('shows validation errors for empty required fields', async () => {
const user = userEvent.setup();
renderCheckout();
// Submit without filling anything
await user.click(screen.getByRole('button', { name: /place order/i }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Address is required')).toBeInTheDocument();
expect(mockSubmitOrder).not.toHaveBeenCalled();
});
it('handles API failure gracefully', async () => {
const user = userEvent.setup();
mockSubmitOrder.mockRejectedValueOnce(new Error('Network error'));
renderCheckout();
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Address'), '123 Main St');
await user.selectOptions(screen.getByLabelText('Country'), 'US');
await user.click(screen.getByRole('button', { name: /place order/i }));
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
// Submit button should be enabled again for retry
expect(screen.getByRole('button', { name: /place order/i })).toBeEnabled();
});
The API failure test caught a real bug for us. The submit button was being disabled on click but never re-enabled when the request failed. Users were stuck on a page with a grayed-out button and no explanation. The test forced us to handle that case.
Why I Stopped Using Enzyme
Enzyme let you test React components by inspecting their internal structure. You could check state values directly, call methods on component instances, shallow render to avoid rendering child components.
// Enzyme style โ testing implementation
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().increment();
expect(wrapper.state('count')).toBe(1);
This tests that a count state variable exists and that an increment method modifies it. Refactor the component to use useReducer instead of useState, or rename the state variable, and the tests break even though the component works identically from the user's perspective.
Testing Library doesn't give you access to component internals. You can't read state. You can't call methods. You interact with the rendered output the way a user would.
// Testing Library style โ testing behavior
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
Refactor the internals however you want. If the user still sees "Count: 0" and clicking the button changes it to "Count: 1", the test passes. This is what tests should do โ verify behavior, not implementation.
Enzyme also never fully supported React hooks, function components, or concurrent features. It was built for a class component world. React moved on. Testing Library was designed for the React that exists now.
End-to-End Tests โ Expensive but Irreplaceable
Integration tests with Testing Library catch most interaction bugs but run against a simulated DOM (jsdom). No real browser. No real CSS rendering. No real network requests. That invisible overlay div I mentioned at the start? jsdom wouldn't catch it either because it doesn't do layout calculations.
E2E tests run in a real browser. Playwright or Cypress drives Chrome, Firefox, or Safari through actual user interactions. CSS applies. Layout calculates. Network requests fire. This is the only test type that catches visual and layout bugs.
import { test, expect } from '@playwright/test';
test('checkout flow completes successfully', async ({ page }) => {
await page.goto('/products');
// Add item to cart
await page.getByRole('button', { name: 'Add to cart' }).first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Navigate to checkout
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
// Fill shipping details
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('Country').selectOption('US');
// Complete order
await page.getByRole('button', { name: /place order/i }).click();
// Verify confirmation
await expect(page.getByText(/order confirmed/i)).toBeVisible();
await expect(page.getByText(/order #/i)).toBeVisible();
});
The tradeoff is real. This test takes 5-15 seconds instead of 50 milliseconds. It needs a running server. It can flake if the server is slow or if animations interfere with element detection. Running a full E2E suite on every commit would probably slow development to a crawl.
My rule: E2E tests for critical user journeys only. The checkout flow. The login/signup flow. The core feature that makes the product valuable. Maybe 10-20 tests total. Run them in CI on pull requests, not on every local save.
Playwright Over Cypress
Used Cypress for two years before switching to Playwright. The reasons were practical. Playwright runs tests in parallel out of the box โ Cypress parallelization requires a paid dashboard. Playwright supports multiple browser engines natively โ we caught a Safari-specific rendering bug that Cypress on Chromium would never have found. Playwright's auto-waiting seems more reliable โ less manual cy.wait() calls that make tests brittle.
Cypress has a nicer interactive test runner for local development. I'll give it that. But for CI reliability and cross-browser coverage, Playwright wins for my use cases.
The Coverage Lie
Code coverage is the most misunderstood metric in testing. 90% coverage sounds great. It means 90% of your code lines were executed during tests. What it doesn't mean: 90% of your application works correctly.
I've seen codebases with 95% coverage that were riddled with bugs. The tests executed nearly every line but didn't assert anything meaningful. A test that renders a component and checks expect(wrapper).toBeTruthy() technically covers all the lines in that component. It catches exactly zero bugs.
// This "test" gives you coverage but catches nothing
it('renders without crashing', () => {
render(<ComplexDashboard />);
// That's it. No assertions about what rendered.
// Every line of ComplexDashboard was executed.
// Coverage: 100%. Value: approximately zero.
});
Coverage also can't tell you about missing tests. If you never wrote a test for the error state when the API returns a 500, coverage doesn't penalize you โ because there's no code for that case either. The missing error handling and the missing test for it are both invisible to the coverage tool.
Where coverage is genuinely useful: finding untested code paths. If a critical utility function shows 30% coverage, that's a signal to write more tests for it. Use coverage as a detection tool for gaps, not as a quality metric. A coverage gate in CI (fail the build below X%) prevents the worst case โ shipping with entire modules untested. Just don't let a high number make you complacent.
What to Actually Test โ A Decision Framework
After years of testing poorly and then slightly less poorly, here's the framework I use now.
Always test:
- Business logic functions (calculations, transformations, validations)
- Custom hooks with complex state management
- User flows that involve multiple components interacting
- Error states and edge cases in critical paths
- Accessibility โ screen reader text, keyboard navigation, focus management
Test if you have time:
- Individual component rendering with different prop combinations
- Loading states and skeleton screens
- Responsive behavior at different breakpoints (E2E only)
Skip:
- Components that are pure wrappers with no logic
- Styling details (font sizes, colors, spacing) โ these change constantly and visual regression tools handle them better
- Third-party library behavior โ trust their tests, not yours
- Implementation details (state variable names, internal method calls)
Testing Accessibility
This deserves its own mention because it's both important and undertested at most organizations. Testing Library encourages accessible queries by design โ getByRole, getByLabelText, getByAltText. If you can't find your element with these queries, your component might not be accessible.
it('supports keyboard navigation through menu items', async () => {
const user = userEvent.setup();
render(<DropdownMenu items={['Edit', 'Delete', 'Archive']} />);
// Open menu
await user.click(screen.getByRole('button', { name: /actions/i }));
// First item should be focused
expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus();
// Arrow down moves focus
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Delete' })).toHaveFocus();
// Escape closes menu
await user.keyboard('{Escape}');
expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();
});
This test verifies keyboard accessibility behavior that manual testing almost never catches. It takes thirty seconds to write and prevents a category of accessibility bugs that affects a significant percentage of users.
Mocking โ The Necessary Evil
Mocking external dependencies is unavoidable in frontend tests. API calls, browser APIs, third-party services. The question is how much to mock.
My rule: mock at the network boundary, not at the module boundary.
// Prefer: mock the network layer
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/user', () => {
return HttpResponse.json({ id: 1, name: 'Anurag', role: 'admin' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('displays user info after loading', async () => {
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Anurag')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
});
MSW (Mock Service Worker) intercepts network requests, so your actual fetch calls and data parsing logic all run for real. Only the network response is faked. This catches bugs in your API client, your response parsing, your error handling โ things that mocking the module directly would skip over.
Compare to what I used to do:
// Avoid: mock internal modules
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Anurag', role: 'admin' })
}));
This skips the actual fetch call. If someone changes the API endpoint, the response shape, or the error handling in the API module, this test still passes. Useless.
Test Organization That Doesn't Make You Hate Your Life
Tried multiple approaches. Separate __tests__ directories, tests next to components, a top-level tests folder mirroring the source structure. Settled on colocation โ test files next to the code they test.
src/
components/
CheckoutForm/
CheckoutForm.tsx
CheckoutForm.test.tsx
CheckoutForm.stories.tsx
hooks/
useDebounce.ts
useDebounce.test.ts
utils/
shipping.ts
shipping.test.ts
e2e/
checkout.spec.ts
auth.spec.ts
E2E tests get their own directory because they don't test specific files โ they test user journeys across the entire application. Unit and integration tests live next to their subjects. When you open CheckoutForm.tsx, the test file is right there. No navigating to a mirror directory structure.
The Tests I Wish I'd Written Sooner
Looking back at production bugs from the last two years, most of them would have been caught by a handful of integration tests I was too lazy or too busy to write.
Form submission with special characters breaking the API. An empty state component that never rendered because the loading state had a typo in its conditional check. A date picker that worked in every timezone except UTC-12. A search input that fired an API request on every keystroke instead of debouncing because someone removed the useEffect dependency.
None of these were complex bugs. They were all in the connections between components, in the edge cases of user input, in the scenarios that nobody thought to test manually. A few thoughtful integration tests covering real user workflows with realistic data would have caught every single one.
That's the lesson I keep re-learning: write tests for the things that will embarrass you when they break in production. Not the things that are easy to test. Not the things that give you good coverage numbers. The things that your users will actually encounter. That mindset shift โ from testing code to testing behavior โ made my test suite smaller, my test time shorter, and my confidence in shipping dramatically higher.
Keep Reading
- Clean Code Without the Dogma โ What Actually Matters in Practice โ Good tests protect clean code; clean code makes tests easier to write and maintain.
- Web Performance That Actually Matters โ Beyond Lighthouse Scores โ Testing catches functional bugs, but performance issues need their own measurement strategy.
Further Resources
- Testing Library Documentation โ The official docs for React Testing Library, covering queries, user events, and the guiding philosophy of testing user behavior over implementation.
- Kent C. Dodds Blog โ In-depth articles on testing best practices, the testing trophy, and practical strategies from the creator of Testing Library.
- Playwright Documentation โ The official guide to end-to-end testing with Playwright, covering browser automation, assertions, and CI integration.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

Browser DevTools โ Way Beyond console.log
The debugging features hiding in plain sight that took me years to discover. Performance profiling, memory leak hunting, network simulation, and the snippets panel I now use daily.

Next.js App Router Deep Dive โ Server Components, Streaming, and the Caching Trap
What I learned after six months of building with the App Router: when server components shine, when client components are the right call, and why caching will waste your afternoon.

Web Performance That Actually Matters โ Beyond Lighthouse Scores
What actually moves the needle on real user experience: fixing LCP, CLS, and INP with changes that users notice, not just scores that improve.