Testing Strategies: Unit, Integration, E2E, and Contract Testing Explained
Build a comprehensive testing strategy. Unit tests with Vitest, integration tests with Testcontainers, E2E with Playwright, and contract testing with...
The Testing Pyramid in 2025
The testing pyramid is still the best mental model for test distribution:
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>
/\
/ \ E2E Tests (few, slow, expensive)
/ \
/------\
/ \ Integration Tests (moderate)
/ \
/------------\
/ \ Unit Tests (many, fast, cheap)
/________________\Unit Testing with Vitest
Vitest is the modern standard for JavaScript/TypeScript unit testing. It is compatible with Jest's API but significantly faster (native ESM, Vite-powered).
// price-calculator.ts
export function calculatePrice(
basePrice: number,
quantity: number,
discount: number,
taxRate: number
): { subtotal: number; tax: number; total: number } {
if (quantity < 1) throw new Error('Quantity must be at least 1');
if (discount < 0 || discount > 100) throw new Error('Discount must be 0-100');
const subtotal = basePrice * quantity * (1 - discount / 100);
const tax = subtotal * (taxRate / 100);
const total = subtotal + tax;
return {
subtotal: Math.round(subtotal * 100) / 100,
tax: Math.round(tax * 100) / 100,
total: Math.round(total * 100) / 100,
};
}// price-calculator.test.ts
import { describe, it, expect } from 'vitest';
import { calculatePrice } from './price-calculator';
describe('calculatePrice', () => {
it('calculates basic price without discount', () => {
const result = calculatePrice(100, 2, 0, 18);
expect(result).toEqual({
subtotal: 200,
tax: 36,
total: 236,
});
});
it('applies percentage discount correctly', () => {
const result = calculatePrice(100, 1, 20, 18);
expect(result.subtotal).toBe(80);
expect(result.total).toBe(94.4);
});
it('handles fractional cents with rounding', () => {
const result = calculatePrice(10.99, 3, 15, 18);
expect(result.subtotal).toBe(28.02);
expect(result.tax).toBe(5.04);
expect(result.total).toBe(33.06);
});
it('throws on invalid quantity', () => {
expect(() => calculatePrice(100, 0, 0, 18)).toThrow('Quantity must be at least 1');
expect(() => calculatePrice(100, -1, 0, 18)).toThrow('Quantity must be at least 1');
});
it('throws on invalid discount', () => {
expect(() => calculatePrice(100, 1, -5, 18)).toThrow('Discount must be 0-100');
expect(() => calculatePrice(100, 1, 101, 18)).toThrow('Discount must be 0-100');
});
});Mocking dependencies:
// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from './user-service';
// Mock the database module
vi.mock('./database', () => ({
db: {
users: {
findById: vi.fn(),
create: vi.fn(),
},
},
}));
// Mock the email module
vi.mock('./email-service', () => ({
sendEmail: vi.fn().mockResolvedValue(true),
}));
import { db } from './database';
import { sendEmail } from './email-service';
describe('UserService', () => {
const service = new UserService();
beforeEach(() => {
vi.clearAllMocks();
});
it('creates user and sends welcome email', async () => {
vi.mocked(db.users.create).mockResolvedValue({
id: '1', name: 'Jane', email: '[email protected]',
});
const user = await service.createUser({ name: 'Jane', email: '[email protected]' });
expect(db.users.create).toHaveBeenCalledWith({
name: 'Jane', email: '[email protected]',
});
expect(sendEmail).toHaveBeenCalledWith('[email protected]', 'welcome', expect.any(Object));
expect(user.id).toBe('1');
});
});Integration Testing with Testcontainers
Testcontainers spins up real Docker containers for your tests — real PostgreSQL, real Redis, real RabbitMQ. No mocking, no in-memory fakes.
// user-repository.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';
import { runMigrations } from './migrations';
describe('UserRepository (integration)', () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
let repo: UserRepository;
beforeAll(async () => {
// Start a real PostgreSQL container
container = await new PostgreSqlContainer('postgres:16')
.withDatabase('test')
.start();
pool = new Pool({
connectionString: container.getConnectionUri(),
});
// Run real migrations
await runMigrations(pool);
repo = new UserRepository(pool);
}, 60000); // 60s timeout for container startup
afterAll(async () => {
await pool.end();
await container.stop();
});
it('creates and retrieves a user', async () => {
const created = await repo.create({
name: 'Jane Doe',
email: '[email protected]',
});
expect(created.id).toBeDefined();
expect(created.name).toBe('Jane Doe');
const found = await repo.findById(created.id);
expect(found).toEqual(created);
});
it('enforces unique email constraint', async () => {
await repo.create({ name: 'User 1', email: '[email protected]' });
await expect(
repo.create({ name: 'User 2', email: '[email protected]' })
).rejects.toThrow(/unique/i);
});
it('handles pagination correctly', async () => {
// Insert 25 users
for (let i = 0; i < 25; i++) {
await repo.create({ name: 'User ' + i, email: 'user' + i + '@test.com' });
}
const page1 = await repo.findAll({ page: 1, limit: 10 });
const page2 = await repo.findAll({ page: 2, limit: 10 });
const page3 = await repo.findAll({ page: 3, limit: 10 });
expect(page1.length).toBe(10);
expect(page2.length).toBe(10);
expect(page3.length).toBeGreaterThanOrEqual(5);
});
});<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><text x="80" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Input</text><circle cx="80" cy="50" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><circle cx="80" cy="100" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><circle cx="80" cy="150" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><text x="230" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Hidden</text><circle cx="230" cy="45" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="85" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="125" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="165" r="14" fill="#6366f1" opacity="0.8"/><text x="380" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Hidden</text><circle cx="380" cy="55" r="14" fill="#a855f7" opacity="0.8"/><circle cx="380" cy="100" r="14" fill="#a855f7" opacity="0.8"/><circle cx="380" cy="145" r="14" fill="#a855f7" opacity="0.8"/><text x="520" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Output</text><circle cx="520" cy="80" r="14" fill="none" stroke="#2dd4bf" stroke-width="2"/><circle cx="520" cy="130" r="14" fill="none" stroke="#2dd4bf" stroke-width="2"/><line x1="94" y1="50" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="55" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="55" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="100" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="100" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="145" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="145" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Neural network architecture: data flows through input, hidden, and output layers.</p></div>
E2E Testing with Playwright
Playwright tests your application from the user's perspective in a real browser.
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'testpass');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
});
test('complete purchase flow', async ({ page }) => {
// Browse products
await page.goto('/products');
await expect(page.getByText('Product Catalog')).toBeVisible();
// Add item to cart
await page.click('[data-testid="product-widget-1"] >> text=Add to Cart');
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page.getByText('Shopping Cart')).toBeVisible();
await expect(page.getByText('Widget Pro')).toBeVisible();
// Proceed to checkout
await page.click('text=Proceed to Checkout');
// Fill shipping info
await page.fill('[name="address"]', '123 Main St');
await page.fill('[name="city"]', 'Mumbai');
await page.fill('[name="zip"]', '400001');
// Submit order
await page.click('text=Place Order');
// Verify confirmation
await expect(page.getByText('Order Confirmed')).toBeVisible();
await expect(page.getByTestId('order-id')).toBeVisible();
});
test('shows error for out-of-stock items', async ({ page }) => {
await page.goto('/products');
const outOfStockItem = page.getByTestId('product-out-of-stock');
await expect(outOfStockItem.getByText('Out of Stock')).toBeVisible();
await expect(outOfStockItem.getByText('Add to Cart')).toBeDisabled();
});
});Contract Testing with Pact
Contract testing ensures that services agree on API contracts without end-to-end tests.
// Consumer side (frontend/mobile app)
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'WebApp',
provider: 'UserAPI',
});
describe('User API Contract', () => {
it('returns user by ID', async () => {
await provider
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/api/users/123',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: MatchersV3.string('123'),
name: MatchersV3.string('Jane Doe'),
email: MatchersV3.email(),
createdAt: MatchersV3.iso8601DateTime(),
},
})
.executeTest(async (mockServer) => {
const response = await fetch(mockServer.url + '/api/users/123', {
headers: { Accept: 'application/json' },
});
const user = await response.json();
expect(user.id).toBe('123');
expect(user.name).toBeDefined();
expect(user.email).toContain('@');
});
});
});Test Strategy by Layer
|-------|------|---------------|-------|------------|
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 180" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="180" rx="12" fill="#1a1a2e"/><rect x="30" y="55" width="90" height="50" rx="8" fill="#6366f1" opacity="0.9"/><text x="75" y="85" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui">Code</text><rect x="150" y="55" width="90" height="50" rx="8" fill="#3b82f6" opacity="0.9"/><text x="195" y="85" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui">Build</text><rect x="270" y="55" width="90" height="50" rx="8" fill="#a855f7" opacity="0.9"/><text x="315" y="85" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui">Test</text><rect x="390" y="55" width="90" height="50" rx="8" fill="#2dd4bf" opacity="0.9"/><text x="435" y="85" text-anchor="middle" fill="#1a1a2e" font-size="12" font-family="system-ui">Deploy</text><rect x="510" y="55" width="60" height="50" rx="8" fill="#f59e0b" opacity="0.9"/><text x="540" y="85" text-anchor="middle" fill="#1a1a2e" font-size="12" font-family="system-ui">Live</text><path d="M122,80 L148,80" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow1)"/><path d="M242,80 L268,80" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow1)"/><path d="M362,80 L388,80" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow1)"/><path d="M482,80 L508,80" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow1)"/><defs><marker id="arrow1" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><text x="300" y="145" text-anchor="middle" fill="#94a3b8" font-size="11" font-family="system-ui">Continuous Integration / Continuous Deployment Pipeline</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">A typical CI/CD pipeline: code flows through build, test, and deploy stages automatically.</p></div>
CI/CD Pipeline Configuration
# .gitea/workflows/test.yml
name: Test Suite
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:unit -- --coverage
# Runs in ~10 seconds
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
# Requires Docker for Testcontainers, ~2 minutes
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests] # Run after faster tests pass
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run test:e2e
# Runs in ~5 minutesAt TechSaaS, we follow the testing pyramid for all client projects. Unit tests run locally in watch mode during development, integration tests run in CI with Testcontainers (real databases, real Redis), and E2E tests run as a gate before deployment. The key lesson: do not skip integration tests and rely only on unit tests and E2E. Integration tests catch the bugs that unit tests miss without the fragility of E2E tests.
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.