Testing JavaScript Applications: A Practical Approach
I used to hate writing tests. They felt like busywork that slowed down development. Then I joined a team with good tests and realized—they're not about catching bugs. They're about confidence to ship.
The Testing Pyramid
/\ E2E (Few)
/ \
/____\ Integration
/ \
/________\ Unit Tests (Many)
- Unit tests: Fast, isolated, test single functions
- Integration tests: Test modules working together
- E2E tests: Test complete user flows
Unit Testing with Jest
// utils/formatCurrency.js
export function formatCurrency(amount, currency = 'INR') {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency
}).format(amount);
}
// utils/formatCurrency.test.js
import { formatCurrency } from './formatCurrency';
describe('formatCurrency', () => {
test('formats INR correctly', () => {
expect(formatCurrency(1000)).toBe('₹1,000.00');
});
test('formats large numbers with lakhs notation', () => {
expect(formatCurrency(150000)).toBe('₹1,50,000.00');
});
test('handles USD', () => {
expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
});
test('handles zero', () => {
expect(formatCurrency(0)).toBe('₹0.00');
});
});
Testing React Components
// components/Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
test('renders initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
test('increments on button click', () => {
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
Integration Testing
Test how modules work together:
// api/users.test.js
import request from 'supertest';
import app from '../app';
import { db } from '../database';
describe('User API', () => {
beforeEach(async () => {
await db.users.deleteMany();
});
test('POST /api/users creates user and returns 201', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: '[email protected]', name: 'Test User' });
expect(response.status).toBe(201);
expect(response.body.user.email).toBe('[email protected]');
// Verify in database
const dbUser = await db.users.findUnique({
where: { email: '[email protected]' }
});
expect(dbUser).not.toBeNull();
});
test('returns 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid-email', name: 'Test' });
expect(response.status).toBe(400);
});
});
Mocking
// Don't hit real APIs in tests
jest.mock('./services/emailService');
import { sendEmail } from './services/emailService';
test('sends welcome email on registration', async () => {
sendEmail.mockResolvedValue({ success: true });
await registerUser({ email: '[email protected]' });
expect(sendEmail).toHaveBeenCalledWith({
to: '[email protected]',
template: 'welcome'
});
});
E2E Testing with Playwright
// tests/checkout.spec.js
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
// Go to product page
await page.goto('/products/123');
// Add to cart
await page.click('button:has-text("Add to Cart")');
// Go to checkout
await page.click('a:has-text("Checkout")');
// Fill form
await page.fill('#email', '[email protected]');
await page.fill('#phone', '9876543210');
// Complete order
await page.click('button:has-text("Place Order")');
// Verify success
await expect(page.locator('h1')).toHaveText('Order Confirmed');
});
What to Test
Do test:
- Business logic
- Edge cases
- Error handling
- User interactions
Don't test:
- Implementation details
- Third-party libraries
- Obvious code
Testing Philosophy
Write tests that give you confidence, not tests that give you 100% coverage.
Coverage metrics can be misleading. A few well-written integration tests often provide more value than dozens of unit tests.
Start with the highest-risk code and work your way out.