Testing Strategies for Enterprise Applications: A Practical Guide
Testing is where good intentions meet reality. Everyone agrees tests are important. Few teams do it well.
I’ve seen teams with 95% code coverage and broken production deployments. I’ve seen teams with 40% coverage and rock-solid reliability. Coverage percentage doesn’t matter—what you test and how you test it does.
Let me show you a testing strategy that actually works.
The Testing Pyramid (and Why It’s Not Enough)
You’ve seen the pyramid: lots of unit tests, some integration tests, few E2E tests. It’s a good starting point but misses critical context.
The Real Testing Strategy
Unit Tests (70%):
- Test isolated functions and components
- Fast, cheap, easy to maintain
- Catch logic errors
- Give you confidence to refactor
Integration Tests (20%):
- Test how components work together
- Catch interface mismatches
- Verify database queries
- Test API contracts
E2E Tests (10%):
- Test critical user paths
- Verify the whole system works
- Catch UI/UX issues
- Expensive, slow, flaky
The Catch: These percentages shift based on your application. API backend? More integration tests. UI-heavy app? More E2E tests. Functional library? More unit tests.
Unit Testing: The Foundation
Unit tests should be fast, isolated, and test one thing.
What to Unit Test
Pure Functions (always test these):
// Pure function - easy to test
export function calculateDiscount(price: number, discountPercent: number): number {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('Invalid input');
}
return price * (1 - discountPercent / 100);
}
// Test
describe('calculateDiscount', () => {
it('applies discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 10)).toBe(45);
});
it('throws on invalid input', () => {
expect(() => calculateDiscount(-10, 20)).toThrow('Invalid input');
expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
});
it('handles edge cases', () => {
expect(calculateDiscount(100, 0)).toBe(100);
expect(calculateDiscount(100, 100)).toBe(0);
});
});
Business Logic (critical to test):
export class OrderService {
canCancelOrder(order: Order): boolean {
const hoursSinceOrder = (Date.now() - order.createdAt.getTime()) / (1000 * 60 * 60);
if (order.status === 'shipped') return false;
if (order.status === 'cancelled') return false;
if (hoursSinceOrder > 24) return false;
return true;
}
}
// Test
describe('OrderService', () => {
describe('canCancelOrder', () => {
it('allows cancellation within 24 hours', () => {
const order = {
status: 'pending',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 12), // 12 hours ago
};
expect(service.canCancelOrder(order)).toBe(true);
});
it('prevents cancellation after 24 hours', () => {
const order = {
status: 'pending',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 25), // 25 hours ago
};
expect(service.canCancelOrder(order)).toBe(false);
});
it('prevents cancellation of shipped orders', () => {
const order = {
status: 'shipped',
createdAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
};
expect(service.canCancelOrder(order)).toBe(false);
});
});
});
React Components (test behavior, not implementation):
import { render, screen, fireEvent } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
describe('Counter', () => {
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('increments count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('resets count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Reset'));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
});
What NOT to Unit Test
Don’t test the framework:
// Bad - testing React, not your code
it('renders a div', () => {
const { container } = render(<MyComponent />);
expect(container.querySelector('div')).toBeInTheDocument();
});
// Good - testing your component's behavior
it('displays user name', () => {
render(<UserProfile name="Alice" />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
Don’t test implementation details:
// Bad - tests implementation
it('calls useState', () => {
const useStateSpy = jest.spyOn(React, 'useState');
render(<Counter />);
expect(useStateSpy).toHaveBeenCalled();
});
// Good - tests behavior
it('updates count on click', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
Integration Testing: Where Components Meet
Integration tests verify that different parts of your system work together.
API Integration Tests
import { app } from '../src/app';
import request from 'supertest';
import { db } from '../src/database';
describe('POST /api/orders', () => {
beforeEach(async () => {
await db.clean(); // Reset database
});
it('creates an order', async () => {
const user = await db.users.create({ email: 'test@example.com' });
const product = await db.products.create({ name: 'Test Product', price: 100 });
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${user.token}`)
.send({
items: [{ productId: product.id, quantity: 2 }],
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
userId: user.id,
items: [{ productId: product.id, quantity: 2 }],
total: 200,
});
// Verify database
const order = await db.orders.findById(response.body.id);
expect(order).toBeDefined();
});
it('rejects invalid orders', async () => {
const user = await db.users.create({ email: 'test@example.com' });
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${user.token}`)
.send({
items: [], // Empty items
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('items');
});
});
Database Integration Tests
describe('UserRepository', () => {
let repo: UserRepository;
beforeEach(async () => {
await db.migrate.latest();
repo = new UserRepository(db);
});
afterEach(async () => {
await db.migrate.rollback();
});
it('creates and retrieves users', async () => {
const user = await repo.create({
email: 'test@example.com',
name: 'Test User',
});
expect(user.id).toBeDefined();
const retrieved = await repo.findById(user.id);
expect(retrieved).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
});
it('finds user by email', async () => {
await repo.create({ email: 'alice@example.com', name: 'Alice' });
await repo.create({ email: 'bob@example.com', name: 'Bob' });
const user = await repo.findByEmail('alice@example.com');
expect(user?.name).toBe('Alice');
});
it('handles duplicate emails', async () => {
await repo.create({ email: 'test@example.com', name: 'User 1' });
await expect(
repo.create({ email: 'test@example.com', name: 'User 2' })
).rejects.toThrow('Email already exists');
});
});
Service Integration Tests
describe('PaymentService', () => {
let service: PaymentService;
let mockStripe: jest.Mocked<Stripe>;
beforeEach(() => {
mockStripe = {
charges: {
create: jest.fn(),
},
} as any;
service = new PaymentService(mockStripe);
});
it('processes payment successfully', async () => {
mockStripe.charges.create.mockResolvedValue({
id: 'ch_123',
status: 'succeeded',
amount: 5000,
} as any);
const result = await service.processPayment({
amount: 5000,
currency: 'usd',
source: 'tok_visa',
});
expect(result.success).toBe(true);
expect(result.transactionId).toBe('ch_123');
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: 'usd',
source: 'tok_visa',
});
});
it('handles payment failures', async () => {
mockStripe.charges.create.mockRejectedValue(
new Error('Card declined')
);
const result = await service.processPayment({
amount: 5000,
currency: 'usd',
source: 'tok_chargeDeclined',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Card declined');
});
});
End-to-End Testing: The Full Journey
E2E tests simulate real user interactions.
Playwright Example
import { test, expect } from '@playwright/test';
test.describe('Purchase flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://localhost:3000');
});
test('complete purchase', async ({ page }) => {
// Search for product
await page.fill('[data-testid="search-input"]', 'laptop');
await page.click('[data-testid="search-button"]');
// Select product
await page.click('[data-testid="product-0"]');
await expect(page.locator('h1')).toContainText('MacBook Pro');
// Add to cart
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Proceed to checkout
await page.click('[data-testid="cart-button"]');
await page.click('[data-testid="checkout-button"]');
// Fill checkout form
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="cardNumber"]', '4242424242424242');
await page.fill('[name="expiry"]', '12/25');
await page.fill('[name="cvc"]', '123');
// Complete purchase
await page.click('[data-testid="submit-order"]');
// Verify confirmation
await expect(page.locator('h1')).toContainText('Order Confirmed');
await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
});
test('handles out of stock', async ({ page }) => {
await page.goto('https://localhost:3000/products/out-of-stock-item');
await expect(page.locator('[data-testid="add-to-cart"]')).toBeDisabled();
await expect(page.locator('[data-testid="stock-status"]')).toHaveText('Out of Stock');
});
});
Cypress Example
describe('User Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully', () => {
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('password123');
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-name"]').should('contain', 'John Doe');
});
it('shows error for invalid credentials', () => {
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('wrongpassword');
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="error-message"]').should('contain', 'Invalid credentials');
});
it('persists login after refresh', () => {
cy.login('user@example.com', 'password123'); // Custom command
cy.reload();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-name"]').should('contain', 'John Doe');
});
});
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.request('POST', '/api/auth/login', { email, password })
.then((response) => {
localStorage.setItem('token', response.body.token);
});
});
Test Data Management
Factories
import { faker } from '@faker-js/faker';
export const UserFactory = {
build: (overrides?: Partial<User>): User => ({
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
createdAt: faker.date.past(),
...overrides,
}),
buildMany: (count: number, overrides?: Partial<User>): User[] => {
return Array.from({ length: count }, () => UserFactory.build(overrides));
},
};
// Usage
describe('UserService', () => {
it('filters active users', () => {
const users = [
UserFactory.build({ active: true }),
UserFactory.build({ active: false }),
UserFactory.build({ active: true }),
];
const active = service.filterActive(users);
expect(active).toHaveLength(2);
});
});
Database Fixtures
// fixtures/users.ts
export const users = [
{
id: '1',
email: 'admin@example.com',
role: 'admin',
},
{
id: '2',
email: 'user@example.com',
role: 'user',
},
];
// In tests
beforeEach(async () => {
await db.seed.run({ specific: 'users.ts' });
});
Mocking Strategies
When to Mock
External APIs (always mock):
import nock from 'nock';
describe('WeatherService', () => {
it('fetches weather data', async () => {
nock('https://api.weather.com')
.get('/current')
.query({ city: 'London' })
.reply(200, {
temperature: 15,
conditions: 'Cloudy',
});
const weather = await service.getWeather('London');
expect(weather.temperature).toBe(15);
});
});
Time (mock for predictable tests):
import { jest } from '@jest/globals';
describe('SubscriptionService', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01'));
});
afterEach(() => {
jest.useRealTimers();
});
it('detects expired subscriptions', () => {
const subscription = {
expiresAt: new Date('2024-12-31'),
};
expect(service.isExpired(subscription)).toBe(true);
});
});
Database (mock for unit tests, use real for integration):
// Unit test - mock database
const mockRepo = {
findById: jest.fn().mockResolvedValue({ id: '123', name: 'Test' }),
};
const service = new UserService(mockRepo);
// Integration test - real database
const repo = new UserRepository(db);
const service = new UserService(repo);
When NOT to Mock
Don’t mock what you’re testing:
// Bad - mocking the thing you're testing
it('creates user', () => {
const mockCreate = jest.fn().mockResolvedValue({ id: '123' });
service.create = mockCreate;
service.create({ name: 'Alice' });
expect(mockCreate).toHaveBeenCalled(); // Meaningless test
});
// Good - test the real function
it('creates user', async () => {
const user = await service.create({ name: 'Alice' });
expect(user.name).toBe('Alice');
expect(user.id).toBeDefined();
});
Performance Testing
Load Testing
import autocannon from 'autocannon';
describe('API Performance', () => {
it('handles 1000 req/sec', async () => {
const result = await autocannon({
url: 'http://localhost:3000/api/products',
connections: 100,
duration: 10, // seconds
});
expect(result.requests.average).toBeGreaterThan(1000);
expect(result.latency.p99).toBeLessThan(100); // 99th percentile < 100ms
});
});
Memory Leak Detection
describe('Memory leaks', () => {
it('does not leak memory on repeated calls', async () => {
const initialMemory = process.memoryUsage().heapUsed;
for (let i = 0; i < 10000; i++) {
await service.processData({ /* data */ });
}
// Force garbage collection
if (global.gc) global.gc();
const finalMemory = process.memoryUsage().heapUsed;
const increase = finalMemory - initialMemory;
// Memory increase should be minimal
expect(increase).toBeLessThan(10 * 1024 * 1024); // < 10MB
});
});
Test Organization
Arrange-Act-Assert Pattern
describe('OrderService', () => {
it('applies bulk discount', () => {
// Arrange
const items = [
{ price: 100, quantity: 10 },
{ price: 50, quantity: 5 },
];
// Act
const total = service.calculateTotal(items);
// Assert
expect(total).toBe(1125); // 10% bulk discount applied
});
});
Descriptive Test Names
// Bad - vague
it('works', () => { /* ... */ });
// Good - specific
it('applies 10% discount for orders over $1000', () => { /* ... */ });
// Better - full sentence
it('should apply 10% discount when order total exceeds $1000', () => { /* ... */ });
CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:unit
- run: npm run test:integration
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
Common Testing Pitfalls
Over-Mocking
// Bad - too many mocks
const mockDb = { query: jest.fn() };
const mockCache = { get: jest.fn() };
const mockLogger = { log: jest.fn() };
const mockQueue = { add: jest.fn() };
// ... testing nothing real
// Good - mock only external dependencies
const service = new UserService(realDb);
Testing Implementation Details
// Bad
it('uses reduce internally', () => {
const reduceSpy = jest.spyOn(Array.prototype, 'reduce');
calculateTotal(items);
expect(reduceSpy).toHaveBeenCalled();
});
// Good
it('calculates total correctly', () => {
expect(calculateTotal(items)).toBe(150);
});
Flaky Tests
// Bad - race condition
it('updates after delay', async () => {
service.updateAsync();
await sleep(100); // Might not be enough
expect(service.value).toBe(10);
});
// Good - wait for actual condition
it('updates after delay', async () => {
service.updateAsync();
await waitFor(() => expect(service.value).toBe(10));
});
Coverage Targets
Don’t aim for 100% coverage. Aim for testing what matters:
Critical code: 100% coverage
- Payment processing
- Authentication
- Data migrations
- Security logic
Business logic: 90% coverage
- Order processing
- Inventory management
- User workflows
UI components: 70% coverage
- Test user interactions
- Test error states
- Skip trivial rendering
Generated code: 0% coverage
- Don’t test auto-generated code
- Don’t test third-party libraries
Conclusion
Good testing isn’t about coverage percentage—it’s about confidence. Test the things that matter: business logic, critical paths, and edge cases.
Start with unit tests for your logic. Add integration tests for your APIs and database. Use E2E tests sparingly for critical user journeys.
And remember: the best test is the one that catches a bug before it reaches production. The second-best test is the one that makes refactoring safe. Everything else is noise.
Need help implementing a robust testing strategy? VooStack helps teams build comprehensive test suites that give real confidence. Let’s talk about your testing needs.