After writing tests across three companies and multiple domains — fintech at BYJU'S, automotive at Tekion, and travel at Expedia — I've developed opinions about what actually works. Here's my testing strategy for 2025.
The Testing Trophy, Not the Pyramid
The traditional testing pyramid (lots of unit tests, fewer integration tests, fewer E2E tests) doesn't map well to frontend development. I follow the "testing trophy" model:
- Static Analysis (TypeScript + ESLint) — catches typos and type errors
- Integration Tests (the largest layer) — tests components with their dependencies
- Unit Tests — for pure logic, utilities, and hooks
- E2E Tests — critical user flows only
The key insight: integration tests give you the most confidence per line of test code in frontend applications.
Tool Stack
Here's what I use in 2025:
| Purpose | Tool |
|---|---|
| Unit / Integration | Vitest + Testing Library |
| Component Testing | Vitest + jsdom / happy-dom |
| E2E | Playwright |
| Visual Regression | Playwright screenshots |
| API Mocking | MSW (Mock Service Worker) |
| Type Checking | TypeScript strict mode |
Integration Tests: The Core of Your Strategy
Test components the way users interact with them. Not implementation details.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { SearchForm } from './SearchForm';
describe('SearchForm', () => {
it('submits the search query and displays results', async () => {
const user = userEvent.setup();
render();
// Type in the search box
await user.type(screen.getByRole('searchbox'), 'react hooks');
// Submit the form
await user.click(screen.getByRole('button', { name: /search/i }));
// Verify results appear
expect(await screen.findByText(/results for "react hooks"/i)).toBeInTheDocument();
});
it('shows empty state when no results match', async () => {
const user = userEvent.setup();
render();
await user.type(screen.getByRole('searchbox'), 'xyznonexistent');
await user.click(screen.getByRole('button', { name: /search/i }));
expect(await screen.findByText(/no results found/i)).toBeInTheDocument();
});
});
Notice: no mocking of internal state, no testing of implementation details, no snapshot tests. We're testing behavior.
Unit Tests: For Pure Logic Only
Reserve unit tests for functions that transform data:
import { describe, it, expect } from 'vitest';
import { formatCurrency, calculateDiscount, parseSearchParams } from './utils';
describe('formatCurrency', () => {
it('formats USD with two decimal places', () => {
expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50');
});
it('handles zero correctly', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
});
describe('calculateDiscount', () => {
it('applies percentage discount', () => {
expect(calculateDiscount(100, { type: 'percentage', value: 20 })).toBe(80);
});
it('never returns negative values', () => {
expect(calculateDiscount(10, { type: 'fixed', value: 50 })).toBe(0);
});
});
API Mocking with MSW
Mock Service Worker intercepts requests at the network level, so your components make real fetch calls that get intercepted.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const handlers = [
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Umesh Malik',
role: 'engineer',
});
}),
http.post('/api/search', async ({ request }) => {
const { query } = await request.json();
return HttpResponse.json({
results: query === 'xyznonexistent' ? [] : [{ title: 'Result 1' }],
});
}),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW works in both tests and the browser, so you can develop against mocked APIs before the backend is ready.
E2E Tests: Critical Paths Only
E2E tests are slow and flaky. Use them sparingly for flows that involve multiple pages or complex state.
import { test, expect } from '@playwright/test';
test('user can complete checkout flow', async ({ page }) => {
await page.goto('/products');
// Add item to cart
await page.click('[data-testid="add-to-cart-1"]');
await expect(page.locator('.cart-count')).toHaveText('1');
// Go to checkout
await page.click('text=Checkout');
await expect(page).toHaveURL('/checkout');
// Fill shipping form
await page.fill('#email', 'test@example.com');
await page.fill('#address', '123 Test St');
await page.click('button:text("Place Order")');
// Verify confirmation
await expect(page.locator('h1')).toHaveText('Order Confirmed');
});
Testing Hooks
Test custom hooks with renderHook:
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 300));
expect(result.current).toBe('hello');
});
it('debounces value updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'hello' } }
);
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // Not updated yet
act(() => vi.advanceTimersByTime(300));
expect(result.current).toBe('world'); // Updated after delay
});
});
What Not to Test
-
Styling: Don't test that a button has
className="bg-blue-500". Use visual regression tests if styling matters. -
Third-party libraries: Don't test that React Router navigates. Test that your component calls
navigate. - Implementation details: Don't test internal state. Test what the user sees.
-
Constants and config: Don't test that
API_URLequals a string.
Configuration: Vitest Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
});
Key Takeaways
- Invest most of your effort in integration tests — they catch the bugs that matter
- Use MSW for API mocking — it's the most realistic approach
- Keep E2E tests focused on critical business flows
- TypeScript in strict mode is your first line of defense
- Test behavior, not implementation
- A small number of well-written tests beats high coverage of shallow tests
Originally published at umesh-malik.com
Top comments (0)