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:
Docker Compose brings up your entire stack with a single command.
/\
/ \ E2E Tests (few, slow, expensive)
/ \
/------\
/ \ Integration Tests (moderate)
/ \
/------------\
/ \ Unit Tests (many, fast, cheap)
/________________\
- Unit tests: 70% of your tests. Fast, isolated, test one thing.
- Integration tests: 20% of your tests. Test component interactions.
- E2E tests: 10% of your tests. Test full user workflows.
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).
Get more insights on Tutorials
Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.
// 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);
});
});
Neural network architecture: data flows through input, hidden, and output layers.
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.
Free Resource
Free Cloud Architecture Checklist
A 47-point checklist covering security, scalability, cost optimization, and disaster recovery for production cloud environments.
// 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
| Layer | Tool | What it tests | Speed | Confidence |
|---|---|---|---|---|
| Unit | Vitest/Jest | Business logic, pure functions | Very fast (ms) | Low (isolated) |
| Integration | Testcontainers | Database queries, API handlers | Moderate (seconds) | Medium |
| Contract | Pact | API compatibility between services | Fast (ms) | Medium-High |
| E2E | Playwright | Full user workflows | Slow (seconds) | High |
A typical CI/CD pipeline: code flows through build, test, and deploy stages automatically.
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 minutes
At 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.
Related Service
Cloud Solutions
Let our experts help you build the right technology strategy for your business.
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.
We Will Build You a Demo Site — For Free
Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.
No spam. No contracts. Just a free demo.