Why Automated Testing Matters
Without automated tests, CI/CD is just a way to deploy bugs faster. Tests are the safety net that gives your team the confidence to deploy 10+ times per day.
Without Tests With Tests
───────────── ──────────
Push code Push code
↓ ↓
Hope it works 🤞 500 tests run (60 sec)
↓ ↓
Deploy to prod All pass ✅ → Deploy
↓ ↓
Users report bugs 🐛 Monitoring confirms healthy
↓ ↓
Emergency hotfix at 2 AM Team sleeps peacefully 😴
The math is clear: One hour of writing tests saves 10+ hours of debugging in production.
The Testing Pyramid
The testing pyramid is the most important concept in software testing. It defines how many tests of each type you should write.
╱╲
╱ ╲
╱ E2E╲ Few (5-20 tests)
╱──────╲ Slow (5-15 min)
╱ ╲ Expensive ($$$)
╱Integration╲ Some (50-200 tests)
╱──────────────╲ Moderate (2-5 min)
╱ ╲ Middle ($)
╱ Unit Tests ╲ Many (500-5000 tests)
╱─────────────────────╲ Fast (10-60 sec)
Cheap ($)
Why This Shape?
| Level | Speed | Cost | Reliability | Coverage |
|---|---|---|---|---|
| Unit | Milliseconds | Free | Very stable | Functions, logic |
| Integration | Seconds | Low | Mostly stable | API, DB, services |
| E2E | Minutes | High | Can be flaky | Full user flows |
Rule: If you can test it with a unit test, don't use an integration test. If you can test it with an integration test, don't use an E2E test.
Unit Tests
Unit tests verify that individual functions work correctly in isolation. They are the foundation of your test suite.
What to Unit Test
✅ Pure functions (input → output)
✅ Business logic (calculations, validations)
✅ Data transformations (format, parse, convert)
✅ Edge cases (null, empty, boundary values)
✅ Error handling (exceptions, error codes)
✅ Utility functions (helpers, formatters)
Real-World Unit Test Examples
JavaScript (Jest):
// src/utils/pricing.js
function calculateDiscount(price, discountPercent) {
if (price < 0) throw new Error('Price cannot be negative');
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return price * (1 - discountPercent / 100);
}
// tests/utils/pricing.test.js
describe('calculateDiscount', () => {
test('applies 20% discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
test('handles 0% discount', () => {
expect(calculateDiscount(50, 0)).toBe(50);
});
test('handles 100% discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
test('handles decimal prices', () => {
expect(calculateDiscount(29.99, 10)).toBeCloseTo(26.991);
});
test('throws on negative price', () => {
expect(() => calculateDiscount(-10, 20)).toThrow('Price cannot be negative');
});
test('throws on invalid discount', () => {
expect(() => calculateDiscount(100, 150)).toThrow('Discount must be between 0 and 100');
});
});Python (pytest):
# src/auth/password.py
import re
def validate_password(password: str) -> dict:
"""Validate password strength and return results."""
errors = []
if len(password) < 8:
errors.append("Must be at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("Must contain uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("Must contain lowercase letter")
if not re.search(r'\d', password):
errors.append("Must contain a digit")
if not re.search(r'[!@#$%^&*]', password):
errors.append("Must contain special character")
return {"valid": len(errors) == 0, "errors": errors}
# tests/auth/test_password.py
import pytest
from src.auth.password import validate_password
class TestValidatePassword:
def test_valid_password(self):
result = validate_password("MyP@ssw0rd!")
assert result["valid"] is True
assert result["errors"] == []
def test_too_short(self):
result = validate_password("Ab1!")
assert result["valid"] is False
assert "Must be at least 8 characters" in result["errors"]
def test_no_uppercase(self):
result = validate_password("password1!")
assert "Must contain uppercase letter" in result["errors"]
def test_no_special_char(self):
result = validate_password("Password1")
assert "Must contain special character" in result["errors"]
def test_empty_string(self):
result = validate_password("")
assert result["valid"] is False
assert len(result["errors"]) >= 4Unit Test Pipeline Configuration
# GitHub Actions
test-unit:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage --watchAll=false --ci
env:
CI: true
# Enforce coverage thresholds
- name: Check coverage
run: |
npx istanbul check-coverage \
--statements 80 \
--branches 75 \
--functions 80 \
--lines 80
- uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: trueIntegration Tests
Integration tests verify that multiple components work together — your app + database, your API + external service, your microservice + message queue.
What to Integration Test
✅ API endpoint responses (status codes, body, headers)
✅ Database CRUD operations (create, read, update, delete)
✅ Authentication & authorization flows
✅ Third-party service integrations
✅ Message queue publish & consume
✅ Cache behavior (Redis hits/misses)
✅ File upload/download workflows
Real-World Integration Test Examples
API Testing (supertest + Jest):
// tests/integration/users.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db');
describe('Users API', () => {
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
});
afterAll(async () => {
await db.destroy();
});
describe('POST /api/users', () => {
test('creates a new user successfully', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
name: 'New User',
password: 'SecureP@ss1'
})
.expect(201);
expect(response.body).toMatchObject({
email: '[email protected]',
name: 'New User'
});
expect(response.body).not.toHaveProperty('password');
expect(response.body).toHaveProperty('id');
});
test('rejects duplicate email', async () => {
// First, create user
await request(app)
.post('/api/users')
.send({ email: '[email protected]', name: 'First', password: 'Pass1234!' });
// Try duplicate
const response = await request(app)
.post('/api/users')
.send({ email: '[email protected]', name: 'Second', password: 'Pass1234!' })
.expect(409);
expect(response.body.error).toBe('Email already exists');
});
test('validates required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: '[email protected]' })
.expect(400);
expect(response.body.errors).toContain('Name is required');
expect(response.body.errors).toContain('Password is required');
});
});
describe('GET /api/users/:id', () => {
test('returns user by ID', async () => {
const response = await request(app)
.get('/api/users/1')
.set('Authorization', `Bearer ${testToken}`)
.expect(200);
expect(response.body).toHaveProperty('email');
expect(response.body).toHaveProperty('name');
});
test('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/99999')
.set('Authorization', `Bearer ${testToken}`)
.expect(404);
});
});
});Integration Test Pipeline with Real Services
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
rabbitmq:
image: rabbitmq:3-management-alpine
ports: ['5672:5672', '15672:15672']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Run migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration -- --forceExit --detectOpenHandles
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
AMQP_URL: amqp://localhost:5672
NODE_ENV: testEnd-to-End (E2E) Tests
E2E tests simulate real user behavior in a real browser. They're the most comprehensive but also the slowest and most expensive.
What to E2E Test
✅ Critical user journeys (login, checkout, signup)
✅ Cross-page navigation flows
✅ Form submissions with validation
✅ Payment processing (in test mode)
✅ File upload/download from the UI
✅ Responsive design breakpoints
Rule: Only E2E test your critical paths — the flows that make your company money or keep users safe.
Real-World E2E Test Example (Playwright)
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'SecureP@ss1');
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
});
test('complete purchase flow', async ({ page }) => {
// Step 1: Browse products
await page.goto('/products');
await expect(page.locator('[data-testid="product-grid"]')).toBeVisible();
// Step 2: Add to cart
await page.click('[data-testid="product-1"] [data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Step 3: Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
// Step 4: Checkout
await page.click('[data-testid="checkout-button"]');
// Step 5: Fill shipping
await page.fill('[data-testid="address"]', '123 Main St');
await page.fill('[data-testid="city"]', 'New York');
await page.selectOption('[data-testid="state"]', 'NY');
await page.fill('[data-testid="zip"]', '10001');
await page.click('[data-testid="continue-button"]');
// Step 6: Payment (test mode)
const stripeFrame = page.frameLocator('iframe[name*="stripe"]').first();
await stripeFrame.locator('[name="cardnumber"]').fill('4242424242424242');
await stripeFrame.locator('[name="exp-date"]').fill('12/30');
await stripeFrame.locator('[name="cvc"]').fill('123');
// Step 7: Place order
await page.click('[data-testid="place-order"]');
// Step 8: Verify confirmation
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
await expect(page.locator('[data-testid="order-id"]')).not.toBeEmpty();
});
test('handles out-of-stock item', async ({ page }) => {
await page.goto('/products/sold-out-item');
await expect(page.locator('[data-testid="add-to-cart"]')).toBeDisabled();
await expect(page.locator('[data-testid="stock-status"]')).toHaveText('Out of Stock');
});
});E2E Test Pipeline Configuration
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
- name: Run E2E tests
run: npx playwright test
env:
BASE_URL: https://staging.myapp.com
CI: true
# Always upload results (even on failure)
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-screenshots
path: test-results/Smoke Tests
Smoke tests are lightweight, fast checks that verify a deployment is alive and functioning. They run immediately after deployment.
What Smoke Tests Check
✅ Application starts and responds (HTTP 200)
✅ Health endpoint returns healthy status
✅ Database connection works
✅ Critical API endpoints respond
✅ Static assets load (CSS, JS, images)
✅ Authentication endpoint works
Real-World Smoke Test Script
#!/bin/bash
# scripts/smoke-test.sh
# Run after every deployment
set -e
BASE_URL="${1:-https://staging.myapp.com}"
TIMEOUT=5
MAX_RETRIES=10
echo "🔥 Running smoke tests against: $BASE_URL"
# 1. Health check (with retries)
echo "→ Testing health endpoint..."
for i in $(seq 1 $MAX_RETRIES); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time $TIMEOUT "$BASE_URL/health")
if [ "$STATUS" = "200" ]; then
echo " ✅ Health: OK"
break
fi
if [ "$i" = "$MAX_RETRIES" ]; then
echo " ❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo " ⏳ Retrying ($i/$MAX_RETRIES)..."
sleep 5
done
# 2. API status
echo "→ Testing API status..."
RESPONSE=$(curl -s --max-time $TIMEOUT "$BASE_URL/api/status")
VERSION=$(echo "$RESPONSE" | jq -r '.version')
echo " ✅ API version: $VERSION"
# 3. Response time
echo "→ Testing response time..."
RESPONSE_TIME=$(curl -s -o /dev/null -w "%{time_total}" --max-time $TIMEOUT "$BASE_URL")
echo " ✅ Response time: ${RESPONSE_TIME}s"
if (( $(echo "$RESPONSE_TIME > 3.0" | bc -l) )); then
echo " ⚠️ Warning: Response time exceeds 3 seconds"
fi
# 4. Static assets
echo "→ Testing static assets..."
curl -sf --max-time $TIMEOUT "$BASE_URL/favicon.ico" > /dev/null
echo " ✅ Static assets: OK"
# 5. Database connectivity (via API)
echo "→ Testing database connection..."
DB_STATUS=$(curl -s --max-time $TIMEOUT "$BASE_URL/api/health/db" | jq -r '.status')
if [ "$DB_STATUS" = "connected" ]; then
echo " ✅ Database: Connected"
else
echo " ❌ Database: $DB_STATUS"
exit 1
fi
echo ""
echo "✅ All smoke tests passed!"Test Coverage
Test coverage measures what percentage of your code is exercised by tests. It's a critical metric for pipeline quality gates.
Coverage Types
| Type | What It Measures | Target |
|---|---|---|
| Statement | Lines of code executed | 80%+ |
| Branch | If/else paths taken | 75%+ |
| Function | Functions called | 80%+ |
| Line | Source lines hit | 80%+ |
Enforcing Coverage in CI
# GitHub Actions - Coverage Gate
- name: Run tests with coverage
run: npm test -- --coverage --watchAll=false
- name: Check coverage thresholds
run: |
# Jest coverage config in package.json
# "coverageThreshold": {
# "global": {
# "branches": 75,
# "functions": 80,
# "lines": 80,
# "statements": 80
# }
# }
echo "Coverage thresholds enforced by Jest config"
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
flags: unittestsCoverage Best Practices
✅ Never let coverage decrease — Use tools like Codecov to block PRs that reduce coverage
✅ Focus on critical paths — 100% coverage on payment logic, 60% on admin pages is fine
✅ Don't chase 100% — Diminishing returns above 90%. Focus on quality over quantity
❌ Don't game coverage — Writing expect(true).toBe(true) helps nobody
Dealing with Flaky Tests
Flaky tests are tests that pass sometimes and fail sometimes without code changes. They are the #1 source of CI frustration.
Common Causes & Fixes
| Cause | Example | Fix |
|---|---|---|
| Timing issues | setTimeout in tests | Use waitFor() or retry logic |
| Shared state | Test A modifies DB, Test B reads it | Isolate test data, use transactions |
| Network calls | External API is slow/down | Mock external services |
| Date/time | Test passes at 11 PM, fails at midnight | Mock Date.now() |
| Random ordering | Tests depend on execution order | Make tests independent |
| Resource leaks | Open DB connections not closed | Use afterAll() cleanup |
Strategies for Managing Flakiness
# 1. Automatic retries (GitHub Actions)
test:
runs-on: ubuntu-latest
steps:
- name: Run tests with retries
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: npm test
# 2. Quarantine flaky tests
# Move to a separate suite that doesn't block deploys
- name: Run stable tests (blocking)
run: npm test -- --testPathIgnorePatterns="flaky"
- name: Run flaky tests (non-blocking)
run: npm test -- --testPathPattern="flaky" || trueFlaky Test Tracking
Week 1: 5 flaky tests identified
Week 2: 3 fixed, 1 quarantined, 1 investigating
Week 3: 1 fixed, 0 remaining
Week 4: 0 flaky tests ← Goal!
Rule: Never just "retry and hope." Track, fix, or quarantine flaky tests immediately.
Complete Testing Pipeline
Here's a comprehensive testing setup that covers all levels:
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage --ci
- uses: codecov/codecov-action@v4
with:
flags: unit-node-${{ matrix.node }}
integration:
name: Integration Tests
runs-on: ubuntu-latest
needs: unit
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: integration
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
smoke:
name: Smoke Tests
runs-on: ubuntu-latest
needs: e2e
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- name: Run smoke tests
run: bash scripts/smoke-test.sh ${{ vars.STAGING_URL }}Testing Checklist
Before considering your test suite complete, verify:
| Category | Requirement | Status |
|---|---|---|
| Unit | 80%+ code coverage | ☐ |
| Unit | All edge cases covered | ☐ |
| Unit | No flaky tests | ☐ |
| Integration | API endpoints tested | ☐ |
| Integration | Database operations tested | ☐ |
| Integration | Auth flows tested | ☐ |
| E2E | Critical user journeys covered | ☐ |
| E2E | Cross-browser testing | ☐ |
| Smoke | Health checks in place | ☐ |
| Smoke | Post-deploy validation | ☐ |
| Pipeline | Tests block merges on failure | ☐ |
| Pipeline | Coverage cannot decrease | ☐ |
| Pipeline | Flaky tests tracked & fixed | ☐ |