Testing Pyramid for SaaS
Effective testing balances coverage and speed. The testing pyramid shows relative test distribution:
- Bottom (many): Unit tests (fast, isolated)
- Middle (moderate): Integration tests (realistic, medium speed)
- Top (few): E2E tests (user-realistic, slow)
Inverted pyramids (many E2E, few unit tests) are slow and brittle.
Unit Tests: Fast and Focused
Unit tests verify single functions in isolation. Mock external dependencies.
import { calculateDiscount } from '@/utils/pricing';
describe('calculateDiscount', () => {
test('returns 10% discount for annual plans', () => {
expect(calculateDiscount('annual')).toBe(0.10);
});
test('returns 0% discount for monthly plans', () => {
expect(calculateDiscount('monthly')).toBe(0);
});
test('throws on invalid plan type', () => {
expect(() => calculateDiscount('invalid')).toThrow();
});
});
Run thousands of unit tests in seconds. Developers run constantly—catch bugs instantly.
Integration Tests: Realistic Scenarios
Integration tests verify components working together: API + database + cache.
import { createUser, getUser } from '@/services/user';
import { db } from '@/db';
describe('User service', () => {
beforeAll(async () => {
await db.connect();
});
afterEach(async () => {
await db.query('DELETE FROM users');
});
test('creates and retrieves user', async () => {
const user = await createUser({
email: 'john@example.com',
name: 'John'
});
const retrieved = await getUser(user.id);
expect(retrieved.email).toBe('john@example.com');
});
test('handles duplicate emails', async () => {
await createUser({ email: 'john@example.com', name: 'John' });
await expect(
createUser({ email: 'john@example.com', name: 'Jane' })
).rejects.toThrow('Email exists');
});
});
Integration tests run slower (involve database) but catch real issues.
API Testing
Test API endpoints with realistic requests:
import supertest from 'supertest';
import app from '@/app';
const request = supertest(app);
describe('POST /api/users', () => {
test('creates user', async () => {
const response = await request
.post('/api/users')
.send({
email: 'john@example.com',
password: 'secure123'
});
expect(response.status).toBe(201);
expect(response.body.user.email).toBe('john@example.com');
});
test('returns 400 on invalid email', async () => {
const response = await request
.post('/api/users')
.send({
email: 'invalid',
password: 'secure123'
});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/email/i);
});
});
Component Testing
Test React components without full app:
import { render, screen, userEvent } from '@testing-library/react';
import LoginForm from '@/components/LoginForm';
describe('LoginForm', () => {
test('submits form with email and password', async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'john@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'secure123');
await userEvent.click(screen.getByText(/login/i));
expect(onSubmit).toHaveBeenCalledWith({
email: 'john@example.com',
password: 'secure123'
});
});
});
E2E Tests: User Workflows
E2E tests drive browser through complete user flows:
import { test, expect } from '@playwright/test';
test('user can sign up and login', async ({ page }) => {
// Navigate to signup
await page.goto('http://localhost:3000/signup');
// Fill signup form
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('input[name="password"]', 'secure123');
await page.click('button:has-text("Sign Up")');
// Expect redirect to dashboard
await expect(page).toHaveURL('http://localhost:3000/dashboard');
// Logout
await page.click('[data-testid="user-menu"]');
await page.click('button:has-text("Logout")');
// Login again
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('input[name="password"]', 'secure123');
await page.click('button:has-text("Login")');
await expect(page).toHaveURL('http://localhost:3000/dashboard');
});
E2E tests verify actual product. Slower but catch integration issues unit tests miss.
Coverage Goals
Aim for 80%+ code coverage, but don't obsess over 100%. Some code paths difficult to test:
- Error recovery paths
- Rare edge cases
- External service failures
Perfect coverage with poor tests worse than good coverage with meaningful tests.
Continuous Testing
Run tests continuously:
- Commit: Unit tests must pass before pushing
- Pull request: Unit + integration tests
- Staging: Full test suite + E2E tests
- Production: Smoke tests verifying critical paths
Fail fast: catch bugs before reaching customers.
Frequently asked questions
How many tests should we write per feature?
Rough guideline: 3-5 unit tests per function, 2-3 integration tests per endpoint, 1-2 E2E tests per user flow. Quality over quantity. One good test beats ten shallow tests.
How do we test with external APIs (payments, SMS)?
Mock external APIs in tests. Use services like Stripe test mode. Don't call real APIs in test suite—expensive and slow. Save integration tests with real APIs for staging environment.
How do we handle flaky tests?
Flaky tests (sometimes pass, sometimes fail) are quality destroyers. Identify: run test 10 times, if fails <2 times, it's flaky. Fix: add explicit waits for async operations, increase timeouts, isolate test data better, mock non-deterministic behavior.