TestingCode Coverage

Code Coverage

Understand code coverage metrics, tools, and strategies to measure and improve test effectiveness while maintaining quality over quantity.

What is Code Coverage?

Code coverage is a metric that measures the percentage of your source code that is executed when your test suite runs. It helps identify untested parts of your codebase.

⚠️

Important: High coverage doesn’t guarantee bug-free code. Coverage measures quantity of tested code, not quality of tests.

Why Code Coverage Matters

  • Identify gaps: Find untested code paths
  • Improve confidence: Know what’s tested
  • Guide testing: Understand where to focus testing efforts
  • Track progress: Monitor testing efforts over time
  • Prevent regressions: Ensure new code is tested

Use coverage as a guide, not a goal. Focus on writing meaningful tests rather than chasing 100% coverage.


Types of Coverage

1. Statement Coverage (Line Coverage)

Measures the percentage of statements executed during tests.

function divide(a: number, b: number): number {
  if (b === 0) {                    // Line 1
    throw new Error('Division by zero'); // Line 2
  }
  return a / b;                     // Line 3
}
 
// Test with 66% statement coverage (2/3 lines)
it('should divide numbers', () => {
  expect(divide(10, 2)).toBe(5);
  // Lines 1 and 3 executed, Line 2 NOT executed
});
 
// Test with 100% statement coverage (3/3 lines)
describe('divide', () => {
  it('should divide numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });
 
  it('should throw error for division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

2. Branch Coverage (Decision Coverage)

Measures the percentage of decision branches executed (both true and false paths).

function getDiscount(age: number, isMember: boolean): number {
  let discount = 0;
  
  if (age >= 65) {           // Branch 1: true/false
    discount += 10;
  }
  
  if (isMember) {            // Branch 2: true/false
    discount += 5;
  }
  
  return discount;
}
 
// 50% branch coverage (2/4 branches)
it('should give discount to senior members', () => {
  expect(getDiscount(70, true)).toBe(15);
  // Branch 1: true, Branch 2: true (2 branches covered)
});
 
// 100% branch coverage (4/4 branches)
describe('getDiscount', () => {
  it('should give 15% discount to senior members', () => {
    expect(getDiscount(70, true)).toBe(15);
  });
 
  it('should give 10% discount to seniors', () => {
    expect(getDiscount(70, false)).toBe(10);
  });
 
  it('should give 5% discount to members', () => {
    expect(getDiscount(30, true)).toBe(5);
  });
 
  it('should give no discount to non-member adults', () => {
    expect(getDiscount(30, false)).toBe(0);
  });
});

3. Function Coverage

Measures the percentage of functions that have been called during tests.

class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
 
  subtract(a: number, b: number): number {
    return a - b;
  }
 
  multiply(a: number, b: number): number {
    return a * b;
  }
 
  divide(a: number, b: number): number {
    return a / b;
  }
}
 
// 50% function coverage (2/4 functions)
describe('Calculator', () => {
  const calc = new Calculator();
 
  it('should add', () => {
    expect(calc.add(2, 3)).toBe(5);
  });
 
  it('should subtract', () => {
    expect(calc.subtract(5, 3)).toBe(2);
  });
  
  // multiply() and divide() not tested
});

4. Path Coverage

Measures all possible paths through the code. Most comprehensive but often impractical.

function processOrder(
  isVip: boolean,
  hasDiscount: boolean,
  quantity: number
): number {
  let price = quantity * 10;
  
  if (isVip) {              // Path A or B
    price *= 0.9;
  }
  
  if (hasDiscount) {        // Path C or D
    price *= 0.95;
  }
  
  return price;
}
 
// Possible paths:
// 1. isVip=false, hasDiscount=false (B, D)
// 2. isVip=false, hasDiscount=true  (B, C)
// 3. isVip=true,  hasDiscount=false (A, D)
// 4. isVip=true,  hasDiscount=true  (A, C)
 
// 100% path coverage requires 4 test cases
describe('processOrder', () => {
  it('regular customer, no discount', () => {
    expect(processOrder(false, false, 1)).toBe(10);
  });
 
  it('regular customer, with discount', () => {
    expect(processOrder(false, true, 1)).toBe(9.5);
  });
 
  it('VIP customer, no discount', () => {
    expect(processOrder(true, false, 1)).toBe(9);
  });
 
  it('VIP customer, with discount', () => {
    expect(processOrder(true, true, 1)).toBe(8.55);
  });
});

Coverage Types Comparison

TypeWhat it MeasuresDifficultyUsefulness
StatementLines executedEasyGood baseline
BranchAll if/else pathsModerateVery useful
FunctionFunctions calledEasyBasic check
PathAll code pathsHardComprehensive but impractical

Coverage Tools

Istanbul is the most widely used JavaScript coverage tool, integrated into Jest by default.

Installation

# Jest includes Istanbul by default
npm install --save-dev jest
 
# Standalone Istanbul (NYC)
npm install --save-dev nyc

Jest Configuration

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  collectCoverageFrom: [
    'src/**/*.{js,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.test.{js,ts,tsx}',
    '!src/**/__tests__/**',
    '!src/index.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Running Coverage with Jest

# Collect coverage
npm test -- --coverage
 
# Watch mode with coverage
npm test -- --coverage --watchAll
 
# Coverage for specific files
npm test -- --coverage --collectCoverageFrom="src/utils/**"

NYC Configuration

// .nycrc.json
{
  "all": true,
  "include": ["src/**/*.ts"],
  "exclude": [
    "src/**/*.test.ts",
    "src/**/__tests__/**",
    "src/types/**"
  ],
  "reporter": ["text", "html", "lcov"],
  "check-coverage": true,
  "branches": 80,
  "lines": 80,
  "functions": 80,
  "statements": 80
}
# Run tests with NYC
nyc npm test
 
# Generate HTML report
nyc --reporter=html npm test

V8 Coverage

Node.js built-in coverage using V8 engine (faster than Istanbul).

# Use V8 coverage with Node
node --experimental-coverage --test
 
# With c8 wrapper
npm install --save-dev c8
c8 npm test
// package.json
{
  "scripts": {
    "test": "node --test",
    "test:coverage": "c8 --reporter=html --reporter=text npm test"
  }
}

c8 Configuration

// .c8rc.json
{
  "all": true,
  "include": ["src/**/*.js"],
  "exclude": [
    "src/**/*.test.js",
    "coverage/**",
    "dist/**"
  ],
  "reporter": ["text", "html", "lcov"],
  "check-coverage": true,
  "lines": 80,
  "functions": 80,
  "branches": 80,
  "statements": 80
}

Setting Coverage Thresholds

Coverage thresholds enforce minimum coverage percentages, failing builds when coverage drops.

Global Thresholds

// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Per-Directory Thresholds

// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70
    },
    // Stricter for core modules
    './src/core/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90
    },
    // More lenient for UI components
    './src/components/': {
      branches: 60,
      functions: 60,
      lines: 60,
      statements: 60
    }
  }
};

Per-File Thresholds

// jest.config.js
module.exports = {
  coverageThreshold: {
    './src/utils/validation.ts': {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100
    },
    './src/services/payment.ts': {
      branches: 95,
      functions: 95,
      lines: 95,
      statements: 95
    }
  }
};
⚠️

Set realistic thresholds. Starting with 80% is reasonable. Gradually increase as your test suite matures.


Coverage Reports

Text Report (Terminal)

----------------------|---------|----------|---------|---------|-------------------
File                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------------------|---------|----------|---------|---------|-------------------
All files             |   85.71 |       80 |   83.33 |   85.71 |                   
 src                  |     100 |      100 |     100 |     100 |                   
  index.ts            |     100 |      100 |     100 |     100 |                   
 src/utils            |      75 |    66.67 |   71.43 |      75 |                   
  calculator.ts       |     100 |      100 |     100 |     100 |                   
  validation.ts       |      50 |    33.33 |   42.86 |      50 | 15-23,45-67       
----------------------|---------|----------|---------|---------|-------------------

HTML Report (Interactive)

# Generate HTML report
npm test -- --coverage
 
# Open in browser
open coverage/index.html

HTML reports show:

  • Color-coded coverage percentages
  • Line-by-line coverage visualization
  • Uncovered lines highlighted
  • Branch coverage details

LCOV Report (CI/CD Integration)

// jest.config.js
module.exports = {
  coverageReporters: ['lcov', 'text']
};

LCOV format is used by:

  • Codecov
  • Coveralls
  • SonarQube
  • GitHub Actions

When 100% Coverage Isn’t Necessary

Code That Doesn’t Need 100% Coverage

// 1. Simple getters/setters
class User {
  private name: string;
 
  getName(): string {
    return this.name; // Low value to test
  }
 
  setName(name: string): void {
    this.name = name; // Low value to test
  }
}
 
// 2. Type definitions
interface UserData {
  id: string;
  name: string;
  email: string;
}
 
// 3. Configuration objects
export const config = {
  apiUrl: process.env.API_URL || 'http://localhost:3000',
  timeout: 5000
};
 
// 4. Framework boilerplate
export default function App() {
  return <div>Hello World</div>;
}
 
// 5. Third-party integrations (better to use integration tests)
export function trackEvent(event: string): void {
  analytics.track(event); // External service
}

Focus on High-Value Tests

// ✅ High value: Complex business logic
export function calculateTax(
  income: number,
  deductions: number,
  state: string
): number {
  // Complex tax calculation with many branches
  // High value to have 100% coverage here
}
 
// ✅ High value: Critical security functions
export function hashPassword(password: string): string {
  // Password hashing logic
  // Critical to test thoroughly
}
 
// ✅ High value: Edge cases and error handling
export function parseDate(input: string): Date {
  if (!input) {
    throw new Error('Date string is required');
  }
  
  const date = new Date(input);
  
  if (isNaN(date.getTime())) {
    throw new Error('Invalid date format');
  }
  
  return date;
}
 
// ❌ Low value: Simple pass-through
export function getApiUrl(): string {
  return config.apiUrl;
}

Quality Over Quantity

Bad: High Coverage, Low Quality

// ❌ 100% coverage but meaningless tests
describe('Calculator', () => {
  it('should exist', () => {
    const calc = new Calculator();
    expect(calc).toBeDefined(); // Meaningless assertion
  });
 
  it('should have add method', () => {
    const calc = new Calculator();
    expect(typeof calc.add).toBe('function'); // Checking types, not behavior
  });
 
  it('should return something when adding', () => {
    const calc = new Calculator();
    const result = calc.add(2, 2);
    expect(result).toBeDefined(); // Not checking correctness
  });
});

Good: Lower Coverage, High Quality

// ✅ 80% coverage but meaningful tests
describe('Calculator', () => {
  let calc: Calculator;
 
  beforeEach(() => {
    calc = new Calculator();
  });
 
  describe('add', () => {
    it('should add positive numbers', () => {
      expect(calc.add(2, 3)).toBe(5);
    });
 
    it('should add negative numbers', () => {
      expect(calc.add(-2, -3)).toBe(-5);
    });
 
    it('should handle zero', () => {
      expect(calc.add(5, 0)).toBe(5);
    });
  });
 
  describe('divide', () => {
    it('should divide numbers', () => {
      expect(calc.divide(10, 2)).toBe(5);
    });
 
    it('should throw error for division by zero', () => {
      expect(() => calc.divide(10, 0)).toThrow('Division by zero');
    });
 
    it('should handle negative results', () => {
      expect(calc.divide(-10, 2)).toBe(-5);
    });
  });
});

Coverage vs. Test Quality

MetricBad TestsGood Tests
Coverage100%80%
AssertionsWeak (.toBeDefined())Strong (specific values)
Edge CasesNoneComprehensive
MaintenanceBrittleMaintainable
Bug DetectionLowHigh
ValueLowHigh

Practical Coverage Strategies

1. Start with Critical Paths

// Priority 1: Payment processing (100% coverage required)
describe('PaymentProcessor', () => {
  // Comprehensive tests for all scenarios
});
 
// Priority 2: User authentication (95% coverage)
describe('AuthService', () => {
  // Thorough tests for security
});
 
// Priority 3: UI components (70% coverage)
describe('Button', () => {
  // Test main interactions
});

2. Incremental Improvement

// Week 1: Establish baseline
{
  "coverageThreshold": {
    "global": {
      "branches": 50,
      "functions": 50,
      "lines": 50,
      "statements": 50
    }
  }
}
 
// Week 4: Gradual increase
{
  "coverageThreshold": {
    "global": {
      "branches": 60,
      "functions": 60,
      "lines": 60,
      "statements": 60
    }
  }
}
 
// Week 8: Continue improvement
{
  "coverageThreshold": {
    "global": {
      "branches": 75,
      "functions": 75,
      "lines": 75,
      "statements": 75
    }
  }
}

3. Exclude What Doesn’t Matter

// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    
    // Exclude auto-generated files
    '!src/**/*.d.ts',
    '!src/generated/**',
    
    // Exclude test files
    '!src/**/*.test.{ts,tsx}',
    '!src/**/__tests__/**',
    '!src/**/__mocks__/**',
    
    // Exclude configuration
    '!src/config/**',
    
    // Exclude entry points
    '!src/index.ts',
    '!src/main.ts',
    
    // Exclude types
    '!src/types/**'
  ]
};
// scripts/check-coverage-trend.ts
import fs from 'fs';
 
interface CoverageSummary {
  total: {
    lines: { pct: number };
    statements: { pct: number };
    functions: { pct: number };
    branches: { pct: number };
  };
}
 
const previousCoverage: CoverageSummary = JSON.parse(
  fs.readFileSync('.coverage-baseline.json', 'utf8')
);
 
const currentCoverage: CoverageSummary = JSON.parse(
  fs.readFileSync('coverage/coverage-summary.json', 'utf8')
);
 
const threshold = 0.5; // Don't allow >0.5% decrease
 
Object.keys(currentCoverage.total).forEach((key) => {
  const prev = previousCoverage.total[key as keyof typeof previousCoverage.total].pct;
  const curr = currentCoverage.total[key as keyof typeof currentCoverage.total].pct;
  
  if (curr < prev - threshold) {
    console.error(`Coverage decreased for ${key}: ${prev}% → ${curr}%`);
    process.exit(1);
  }
});
 
console.log('Coverage check passed!');

Coverage in Different Contexts

Unit Tests

Aim for 80-90% coverage for unit tests.

// High coverage expected for pure functions
describe('formatCurrency', () => {
  it('should format positive numbers', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });
 
  it('should format negative numbers', () => {
    expect(formatCurrency(-1234.56)).toBe('-$1,234.56');
  });
 
  it('should handle zero', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });
 
  it('should round to 2 decimals', () => {
    expect(formatCurrency(1234.567)).toBe('$1,234.57');
  });
});

Integration Tests

Aim for 60-70% coverage - focus on critical flows.

// Integration test - lower coverage is acceptable
describe('User Registration Flow', () => {
  it('should register user and send email', async () => {
    const user = await registerUser({
      email: 'user@example.com',
      password: 'SecurePass123!'
    });
 
    expect(user.id).toBeDefined();
    expect(emailService.sendWelcome).toHaveBeenCalled();
  });
});

E2E Tests

Coverage is less meaningful - focus on critical user journeys.

// E2E test - coverage not the goal
test('user can complete checkout', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="add-to-cart"]');
  await page.goto('/checkout');
  await page.fill('#card-number', '4242424242424242');
  await page.click('button[type="submit"]');
  await expect(page.locator('.success-message')).toBeVisible();
});

Tools Comparison

ToolSpeedAccuracyFeaturesBest For
Istanbul (NYC)ModerateHighComprehensive, industry standardMost projects
V8/c8FastHighNative V8, minimal overheadNode.js projects
Jest (built-in)ModerateHighIntegrated, easy setupReact/Jest projects
CodecovN/AN/AHosted reporting, PR commentsCI/CD integration
CoverallsN/AN/AHosted reporting, badgesOpen source projects

Coverage Anti-Patterns

❌ Gaming the Numbers

// Don't write tests just to increase coverage
it('should have properties', () => {
  const user = new User();
  expect(user.name).toBeDefined();
  expect(user.email).toBeDefined();
  expect(user.age).toBeDefined();
  // Meaningless assertions
});

❌ Testing Mocks Instead of Logic

// Don't test mocked behavior
it('should return mocked value', () => {
  const mockFn = jest.fn().mockReturnValue(42);
  expect(mockFn()).toBe(42); // Tests the mock, not real code
});

❌ Ignoring Uncovered Critical Code

// Don't ignore critical error handling
function processPayment(amount: number) {
  if (amount <= 0) {
    throw new Error('Invalid amount'); // Untested!
  }
  return chargeCard(amount);
}
 
// Test only covers the happy path
it('should process payment', () => {
  expect(processPayment(100)).toBeDefined();
});

Best Practices Summary

✅ Do This

  • Use coverage as a guide, not a goal
  • Focus on branch coverage over line coverage
  • Test critical paths thoroughly
  • Exclude generated and boilerplate code
  • Set realistic thresholds (70-80% is good)
  • Track coverage trends over time
  • Write meaningful tests first, coverage follows

❌ Avoid This

  • Chasing 100% coverage
  • Writing tests only for coverage
  • Testing trivial code
  • Ignoring test quality
  • Setting unrealistic thresholds
  • Testing implementation details


Additional Resources