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
| Type | What it Measures | Difficulty | Usefulness |
|---|---|---|---|
| Statement | Lines executed | Easy | Good baseline |
| Branch | All if/else paths | Moderate | Very useful |
| Function | Functions called | Easy | Basic check |
| Path | All code paths | Hard | Comprehensive but impractical |
Coverage Tools
Istanbul (NYC) - Most Popular
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 nycJest 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 testV8 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.htmlHTML 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
| Metric | Bad Tests | Good Tests |
|---|---|---|
| Coverage | 100% | 80% |
| Assertions | Weak (.toBeDefined()) | Strong (specific values) |
| Edge Cases | None | Comprehensive |
| Maintenance | Brittle | Maintainable |
| Bug Detection | Low | High |
| Value | Low | High |
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/**'
]
};4. Track Coverage Trends
// 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
| Tool | Speed | Accuracy | Features | Best For |
|---|---|---|---|---|
| Istanbul (NYC) | Moderate | High | Comprehensive, industry standard | Most projects |
| V8/c8 | Fast | High | Native V8, minimal overhead | Node.js projects |
| Jest (built-in) | Moderate | High | Integrated, easy setup | React/Jest projects |
| Codecov | N/A | N/A | Hosted reporting, PR comments | CI/CD integration |
| Coveralls | N/A | N/A | Hosted reporting, badges | Open 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
Related Topics
- Testing Best Practices - Writing quality tests
- CI/CD Integration - Automated coverage reporting
- Testing Fundamentals - Core concepts