I’ve seen teams burn out on testing and teams that ship bugs because they don’t test enough. The difference isn’t effort - it’s strategy. After building test suites for applications used by millions, I’ve learned that the testing pyramid isn’t a rigid rule - it’s a guideline that needs tailoring to your context.
My Experience
We launched a critical financial application without enough testing. The bug that slipped through - a rounding error in currency calculations - cost us $40,000 in reconciliation and three sleepless weekends.
That experience changed everything. We rebuilt our test strategy from scratch: unit tests for logic, integration tests for flows, E2E for critical paths. Six months later, our bug rate dropped significantly.
Here’s how to build a testing strategy that actually works.
The Testing Pyramid
The testing pyramid represents the ideal distribution of test types:
- E2E Tests (Top): Few, slow, expensive - Run rarely: pre-commit, CI
- Integration Tests (Middle): Medium - API flows, component interaction
- Unit Tests (Base): Many, fast, cheap
When to Use Each Layer
| Layer | Purpose | Speed | Examples |
|---|---|---|---|
| Unit | Logic, utilities | Under 100ms | Math functions, helpers |
| Integration | Component interaction | Under 500ms | API flows, user actions |
| E2E | Critical user journeys | Over 30s | Checkout, login |
Unit Testing: The Foundation
What Makes a Good Unit Test
// Test ONE thing
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
const result = calculateDiscount(100, 20);
expect(result).toBe(80);
});
it('returns 0 when discount is 100%', () => {
const result = calculateDiscount(100, 100);
expect(result).toBe(0);
});
it('throws error for negative prices', () => {
expect(() => calculateDiscount(-10, 20)).toThrow();
});
});
Avoiding Test Coupling
// Bad: Tests depend on each other
let user: User;
beforeAll(async () => {
user = await createUser();
});
it('creates a user', () => {
expect(user.id).toBeDefined();
});
it('updates the user', async () => {
user.name = 'Updated';
await updateUser(user);
const fresh = await getUser(user.id);
expect(fresh.name).toBe('Updated'); // Depends on previous test!
});
// Good: Each test is independent
it('creates a user', async () => {
const user = await createUser();
expect(user.id).toBeDefined();
});
it('updates a user', async () => {
const user = await createUser();
user.name = 'Updated';
await updateUser(user);
const fresh = await getUser(user.id);
expect(fresh.name).toBe('Updated');
});
Integration Testing
Testing React Components
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CartProvider } from './CartContext';
import { AddToCartButton } from './AddToCartButton';
function renderWithContext(ui: React.ReactElement) {
return render(
<CartProvider>
{ui}
</CartProvider>
);
}
it('adds item to cart when clicked', async () => {
renderWithContext(<AddToCartButton productId="123" />);
const button = screen.getByRole('button', { name: /add to cart/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/items: 1/i)).toBeInTheDocument();
});
});
it('shows loading state while adding', () => {
renderWithContext(<AddToCartButton productId="123" />);
const button = screen.getByRole('button', { name: /add to cart/i });
fireEvent.click(button);
expect(button).toBeDisabled();
expect(button).toHaveTextContent(/adding\.\.\./i);
});
API Integration Testing
describe('POST /api/users', () => {
it('creates a new user', async () => {
const newUser = {
name: 'Test User',
email: 'test@example.com',
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: newUser.name,
email: newUser.email,
});
});
it('validates email format', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'invalid' })
.expect(400);
expect(response.body.error).toContain('email');
});
});
E2E Testing with Playwright
Critical Path Testing
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
// Setup: Add items to cart via API
await page.route('/api/cart', async route => {
await route.fulfill({ body: { items: [{ id: '1', quantity: 2 }] } });
});
// Navigate to cart
await page.goto('/cart');
await expect(page.locator('.cart-items')).toHaveCount(2);
// Proceed to checkout
await page.click('[data-testid="checkout-button"]');
// Fill shipping info
await page.fill('[data-testid="shipping-name"]', 'John Doe');
await page.fill('[data-testid="shipping-address"]', '123 Main St');
await page.click('[data-testid="continue-payment"]');
// Complete payment (use test card)
await page.fill('[data-testid="card-number"]', '4242424242424242');
await page.fill('[data-testid="expiry"]', '12/26');
await page.fill('[data-testid="cvv"]', '123');
await page.click('[data-testid="place-order"]');
// Verify success
await expect(page.locator('.order-success')).toBeVisible();
await expect(page.locator('.order-number')).toContainText('ORD-');
});
E2E Test Structure
tests/
├── e2e/
│ ├── critical/
│ │ ├── checkout.spec.ts
│ │ ├── login.spec.ts
│ │ └── payment.spec.ts
│ ├── smoke/
│ │ └── navigation.spec.ts
│ └── regression/
│ ├── forms.spec.ts
│ └── filters.spec.ts
Test Coverage: What’s Enough?
| Component Type | Coverage Target | Rationale |
|---|---|---|
| Utilities | 90%+ | Pure functions, easy to test |
| Business Logic | 80%+ | Critical paths |
| UI Components | 70%+ | Integration tests cover most |
| E2E | Critical flows only | Expensive to maintain |
Focus on Behavior, Not Implementation
// Test behavior, not implementation
describe('ShoppingCart', () => {
// Good: Tests what the user sees
it('displays total price with tax', () => {
render(<Cart items={[{ price: 100 }]} taxRate={0.1} />);
expect(screen.getByText('$110.00')).toBeInTheDocument();
});
// Bad: Tests implementation details
it('calls calculateTax with correct rate', () => {
const calculateTax = jest.spyOn(utils, 'calculateTax');
render(<Cart items={[{ price: 100 }]} taxRate={0.1} />);
expect(calculateTax).toHaveBeenCalledWith(100, 0.1);
});
});
What Students Should Do Next
- Start with unit tests for utility functions — They’re easiest to test and provide quick wins
- Add integration tests for API endpoints — These catch the most bugs per line of code
- Write E2E tests for your most critical user flows — Protect your business-critical paths
Testing isn’t about achieving 100% coverage—it’s about building confidence in the code you ship.
Share this article
Ravali
Software Engineer & Content Creator
Ravali writes practical engineering guides for students and developers, combining hands-on project stories, career lessons, and trend-focused technical research.