Testing Strategies for Modern Web Applications
Testing is not optional—it's essential. But testing everything is impossible, and testing nothing is dangerous. The key is finding the right balance. After years of building test suites for production applications, I've learned what works and what doesn't.
The Testing Pyramid
The classic testing pyramid still holds:
/\
/E2E\ ← Few, slow, expensive
/------\
/Integration\ ← Some, medium speed
/------------\
/ Unit Tests \ ← Many, fast, cheap
/----------------\
Unit Tests (70%)
Fast, isolated tests of individual functions/components.
Integration Tests (20%)
Test how components work together.
E2E Tests (10%)
Test complete user flows.
Unit Testing
What to Test
- Business logic - Core functionality
- Edge cases - Boundary conditions
- Error handling - Failure scenarios
- Transformations - Data processing
What NOT to Test
- Framework code - Don't test React, Express, etc.
- Third-party libraries - They're already tested
- Implementation details - Test behavior, not how
Example: Unit Test
// Function to test
function calculateTotal(items, discount = 0) {
if (!Array.isArray(items)) {
throw new Error('Items must be an array');
}
const subtotal = items.reduce((sum, item) => {
if (item.price < 0) {
throw new Error('Item price cannot be negative');
}
return sum + item.price * item.quantity;
}, 0);
return subtotal * (1 - discount);
}
// Tests
describe('calculateTotal', () => {
it('should calculate total for valid items', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 }
];
expect(calculateTotal(items)).toBe(35);
});
it('should apply discount', () => {
const items = [{ price: 100, quantity: 1 }];
expect(calculateTotal(items, 0.1)).toBe(90);
});
it('should throw error for invalid input', () => {
expect(() => calculateTotal(null)).toThrow('Items must be an array');
});
it('should throw error for negative price', () => {
const items = [{ price: -10, quantity: 1 }];
expect(() => calculateTotal(items)).toThrow('Item price cannot be negative');
});
});
Integration Testing
What to Test
- API endpoints - Request/response flow
- Database operations - CRUD operations
- Service interactions - Multiple services working together
Example: API Integration Test
describe('POST /api/users', () => {
beforeEach(async () => {
await db.clear();
});
it('should create a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'john@example.com'
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: 'John Doe',
email: 'john@example.com'
});
// Verify in database
const user = await db.query('SELECT * FROM users WHERE id = $1', [response.body.id]);
expect(user.rows[0].name).toBe('John Doe');
});
it('should return 400 for invalid email', async () => {
await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'invalid-email'
})
.expect(400);
});
});
E2E Testing
What to Test
- Critical user flows - Login, checkout, etc.
- Happy paths - Main user journeys
- Cross-browser - Different browsers
Example: E2E Test
describe('User Registration Flow', () => {
it('should allow user to register and login', async () => {
// Register
await page.goto('http://localhost:3000/register');
await page.fill('#name', 'John Doe');
await page.fill('#email', 'john@example.com');
await page.fill('#password', 'password123');
await page.click('button[type="submit"]');
// Should redirect to login
await page.waitForURL('**/login');
// Login
await page.fill('#email', 'john@example.com');
await page.fill('#password', 'password123');
await page.click('button[type="submit"]');
// Should be logged in
await page.waitForURL('**/dashboard');
expect(await page.textContent('h1')).toBe('Dashboard');
});
});
Test Organization
Structure
tests/
unit/
services/
userService.test.js
utils/
helpers.test.js
integration/
api/
users.test.js
orders.test.js
e2e/
flows/
registration.test.js
checkout.test.js
Test Data Management
Use Factories
// factories/userFactory.js
function createUser(overrides = {}) {
return {
id: faker.uuid(),
name: faker.name.fullName(),
email: faker.internet.email(),
...overrides
};
}
// In tests
const user = createUser({ name: 'John Doe' });
Use Fixtures
// fixtures/users.js
export const adminUser = {
id: '1',
name: 'Admin',
email: 'admin@example.com',
role: 'admin'
};
export const regularUser = {
id: '2',
name: 'User',
email: 'user@example.com',
role: 'user'
};
Mocking and Stubbing
When to Mock
- External APIs - Don't hit real APIs in tests
- Database - Use test database or mocks
- Time - Mock dates for time-sensitive tests
- Random values - Mock for deterministic tests
Example: Mocking External API
// Mock external API
jest.mock('../services/paymentService', () => ({
chargeCard: jest.fn()
}));
test('should process payment', async () => {
const { chargeCard } = require('../services/paymentService');
chargeCard.mockResolvedValue({ success: true, transactionId: '123' });
const result = await processOrder(order);
expect(chargeCard).toHaveBeenCalledWith(order.payment);
expect(result.success).toBe(true);
});
Test Coverage
Aim for Meaningful Coverage
- 80%+ coverage is a good target
- 100% coverage is often not worth it
- Focus on critical paths - Business logic, error handling
Coverage Tools
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.test.{js,jsx}',
'!src/index.js'
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Testing Best Practices
1. Write Tests First (TDD)
// Write test first
test('should calculate discount', () => {
expect(calculateDiscount(100, 0.1)).toBe(90);
});
// Then implement
function calculateDiscount(price, discount) {
return price * (1 - discount);
}
2. Test Behavior, Not Implementation
// Bad: Tests implementation
test('should call setState', () => {
const setState = jest.fn();
component.setState = setState;
component.handleClick();
expect(setState).toHaveBeenCalled();
});
// Good: Tests behavior
test('should update count when clicked', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
3. Keep Tests Independent
// Bad: Tests depend on each other
let counter = 0;
test('should increment', () => {
counter++;
expect(counter).toBe(1);
});
test('should increment again', () => {
counter++; // Depends on previous test
expect(counter).toBe(2);
});
// Good: Tests are independent
test('should increment', () => {
const counter = new Counter();
counter.increment();
expect(counter.value).toBe(1);
});
test('should increment again', () => {
const counter = new Counter();
counter.increment();
counter.increment();
expect(counter.value).toBe(2);
});
4. Use Descriptive Test Names
// Bad
test('test1', () => { ... });
test('works', () => { ... });
// Good
test('should return error when email is invalid', () => { ... });
test('should create user with valid data', () => { ... });
Continuous Testing
Run Tests in CI/CD
# .github/workflows/test.yml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm test
- run: npm run test:coverage
Real-World Example
Challenge: Large codebase with low test coverage, frequent bugs in production.
Strategy:
- Started with critical paths - Payment, authentication
- Added unit tests - Business logic functions
- Added integration tests - API endpoints
- Added E2E tests - Critical user flows
- Set coverage thresholds - 80% for new code
Result:
- Coverage: 20% → 85%
- Production bugs: 10/month → 1/month
- Deployment confidence: High
Best Practices Summary
- Test behavior, not implementation - Focus on what, not how
- Keep tests fast - Slow tests don't get run
- Write readable tests - Tests are documentation
- Test edge cases - Boundary conditions matter
- Mock external dependencies - Isolate your code
- Maintain test data - Use factories and fixtures
- Run tests in CI - Catch issues early
- Review test failures - They're learning opportunities
Conclusion
Good testing is about confidence—confidence that your code works, confidence to deploy, confidence to refactor. The testing pyramid provides a framework, but the most important thing is to:
- Start testing - Even imperfect tests are better than none
- Test what matters - Focus on critical paths
- Iterate - Tests improve over time
- Maintain - Keep tests up to date
Remember: Tests are not a burden—they're an investment in code quality and team confidence.
What testing challenges have you faced? What strategies have worked best for your team?
Related Posts
TypeScript Best Practices: Writing Maintainable Enterprise Code
Master TypeScript patterns and practices that make enterprise codebases maintainable, scalable, and type-safe. Learn from real-world examples and common pitfalls.
Optimizing Frontend Performance: Beyond Code Splitting
Advanced frontend performance optimization techniques that go beyond basic code splitting. Learn how to achieve sub-second load times and smooth 60fps interactions.
AI Security and Privacy: Building Trustworthy AI Applications
Understand critical security and privacy considerations when building AI applications. Learn about prompt injection attacks, data privacy regulations, model safety, and how to build AI systems users can trust.
The Art of Code Refactoring: When and How to Refactor Legacy Code
Learn the art and science of refactoring legacy code. Discover when to refactor, how to do it safely, and techniques that have transformed unmaintainable codebases.
Building Accessible Web Applications: A Developer's Guide
Learn how to build web applications that are accessible to everyone. From semantic HTML to ARIA attributes, master the techniques that make the web inclusive.
Documentation Best Practices: Writing Code That Documents Itself
Learn how to write effective documentation that helps your team understand and maintain code. From code comments to API docs, master the art of clear communication.