G
GuideDevOps
Lesson 7 of 11

Automated Testing in CI

Part of the CI/CD Pipelines tutorial series.

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?

LevelSpeedCostReliabilityCoverage
UnitMillisecondsFreeVery stableFunctions, logic
IntegrationSecondsLowMostly stableAPI, DB, services
E2EMinutesHighCan be flakyFull 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"]) >= 4

Unit 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: true

Integration 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: test

End-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

TypeWhat It MeasuresTarget
StatementLines of code executed80%+
BranchIf/else paths taken75%+
FunctionFunctions called80%+
LineSource lines hit80%+

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: unittests

Coverage 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

CauseExampleFix
Timing issuessetTimeout in testsUse waitFor() or retry logic
Shared stateTest A modifies DB, Test B reads itIsolate test data, use transactions
Network callsExternal API is slow/downMock external services
Date/timeTest passes at 11 PM, fails at midnightMock Date.now()
Random orderingTests depend on execution orderMake tests independent
Resource leaksOpen DB connections not closedUse 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" || true

Flaky 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:

CategoryRequirementStatus
Unit80%+ code coverage
UnitAll edge cases covered
UnitNo flaky tests
IntegrationAPI endpoints tested
IntegrationDatabase operations tested
IntegrationAuth flows tested
E2ECritical user journeys covered
E2ECross-browser testing
SmokeHealth checks in place
SmokePost-deploy validation
PipelineTests block merges on failure
PipelineCoverage cannot decrease
PipelineFlaky tests tracked & fixed