Measure and interpret statement, branch, function, and line coverage metrics, configure coverage thresholds, and generate HTML reports to identify untested code paths.
Code coverage answers the question: which lines of your code did the test suite actually execute? Istanbul, the coverage engine built into Jest, tracks four metrics — statements, branches, functions, and lines — and produces reports that show exactly which paths through your code are tested and which are not.
Coverage is a useful signal but a dangerous target. A file can have 100% line coverage and still have a critical bug in an untested logical branch. This lesson teaches you to read coverage data as a diagnostic tool — a pointer to missing tests — rather than a badge of honour. By the end you will be able to configure Jest coverage thresholds that block CI on regressions, read HTML reports to find the gaps that matter most, and avoid the traps that make coverage misleading.
Istanbul tracks four distinct coverage types. Understanding what each measures is essential for interpreting reports correctly.
Statement coverage: what percentage of executable statements were run at least once. const x = 1 is a statement. A function declaration is a statement.
Branch coverage: what percentage of decision branches were taken. An if has two branches (true and false). A ternary has two. A switch has one per case. This is the most revealing metric.
Function coverage: what percentage of functions were called at least once.
Line coverage: what percentage of source lines were executed. Differs from statement coverage only when multiple statements appear on one line.
1// src/discount.ts
2export function getDiscount(tier: 'bronze' | 'silver' | 'gold', price: number): number {
3 // Branch 1: tier === 'gold' (true / false)
4 // Branch 2: tier === 'silver' (true / false)
5 // Branch 3: price > 1000 (true / false)
6 if (tier === 'gold') {
7 return price * 0.25;
8 } else if (tier === 'silver') {
9 return price * 0.15;
10 } else {
11 return price > 1000 ? price * 0.1 : price * 0.05; // nested branch
12 }
13}
14
15// If we ONLY test:
16test('gold tier gets 25% off', () => {
17 expect(getDiscount('gold', 100)).toBe(25);
18});
19
20// Coverage result:
21// Statements: 2/5 = 40%
22// Branches: 2/8 = 25% ← reveals the gap
23// Functions: 1/1 = 100% ← misleadingly high
24// Lines: 3/7 = 43%
25
26// After adding tests for silver, bronze < 1000, bronze > 1000:
27// Branches: 8/8 = 100%Branch coverage is the metric to prioritise. A function with 100% line coverage can still miss the else branch of an if (error) guard — exactly where production bugs hide.
Jest uses Istanbul under the hood. Configuration belongs in jest.config.ts:
1// jest.config.ts
2import type { Config } from 'jest';
3
4const config: Config = {
5 preset: 'ts-jest',
6 testEnvironment: 'node',
7
8 // Which files to measure coverage for (not just tested files)
9 collectCoverageFrom: [
10 'src/**/*.ts',
11 '!src/**/*.d.ts', // exclude type declaration files
12 '!src/**/*.test.ts', // exclude test files themselves
13 '!src/**/*.spec.ts',
14 '!src/index.ts', // exclude barrel files (no logic)
15 '!src/**/migrations/**', // exclude generated files
16 ],
17
18 // Coverage report formats
19 coverageReporters: [
20 'text', // terminal summary table
21 'text-summary', // one-line summary
22 'lcov', // for CI tools (SonarQube, Codecov)
23 'html', // browser-viewable detailed report
24 ],
25
26 // Output directory
27 coverageDirectory: 'coverage',
28
29 // Minimum thresholds — build FAILS if any drops below these
30 coverageThreshold: {
31 global: {
32 statements: 80,
33 branches: 80,
34 functions: 85,
35 lines: 80,
36 },
37 // Per-file threshold for critical modules
38 './src/modules/auth/auth.service.ts': {
39 statements: 95,
40 branches: 90,
41 functions: 100,
42 lines: 95,
43 },
44 },
45};
46
47export default config;1# Run with coverage
2npx jest --coverage
3
4# CI command (fails on threshold breach, exits cleanly)
5npx jest --coverage --coverageThreshold='{"global":{"branches":80}}'
6
7# Open HTML report
8npx jest --coverage && open coverage/lcov-report/index.htmlThe HTML report (in coverage/lcov-report/) shows source files annotated with colour-coded coverage:
1// src/auth.service.ts — example of what Istanbul annotates
2export function hashPassword(password: string, saltRounds = 10): string {
3 // ✅ GREEN: this line is covered
4 if (password.length < 8) {
5 // ✅ GREEN: covered by 'short password' test
6 throw new Error('Password too short');
7 }
8 // ✅ GREEN: covered
9 if (saltRounds < 1 || saltRounds > 20) {
10 // 🔴 RED: this branch never entered — no test for invalid saltRounds
11 throw new RangeError('saltRounds must be 1-20');
12 }
13 // ✅ GREEN: covered
14 return bcrypt.hashSync(password, saltRounds);
15}
16
17// Istanbul branch marker in HTML:
18// if (saltRounds < 1 || saltRounds > 20) {
19// I→ ← 'I' means the 'if' branch (true path) was not taken
20// ← the 'E' (else path) was taken by other tests going past this guardReading branch markers:
I = 'if' branch not taken (condition was never true)E = 'else' branch not taken (condition was always true)The HTML file list also shows per-file percentages. Sort by branch coverage ascending to find the files that need the most attention.
High coverage can be misleading. Consider these patterns that produce high coverage without catching real bugs:
1// TRAP 1: Testing the happy path only
2export function parseJson(raw: string): unknown {
3 try {
4 return JSON.parse(raw); // ✅ covered
5 } catch {
6 return null; // 🔴 never tested — JSON.parse never throws in tests
7 }
8}
9
10test('parses valid JSON', () => {
11 expect(parseJson('{"a":1}')).toEqual({ a: 1 });
12 // Line coverage: 3/4 lines = 75% — but the catch is never hit
13 // Branch coverage: 1/2 = 50% — reveals the gap
14});
15
16// FIX: test the error branch
17test('returns null for invalid JSON', () => {
18 expect(parseJson('not json')).toBeNull();
19});
20
21// TRAP 2: Asserting coverage without asserting correctness
22test('processOrder runs without error', () => {
23 processOrder({ id: 'ORD-001', items: [] });
24 // No expect() — this executes the code (increases coverage) but verifies nothing
25 // 100% line coverage, 0 bugs caught
26});
27
28// TRAP 3: Mocking too aggressively
29test('createUser saves to database', async () => {
30 const save = jest.fn().mockResolvedValue({ id: 1 });
31 const result = await createUser({ name: 'Alice' }, { save }); // injected mock
32 expect(result.id).toBe(1);
33 // Branch coverage: high — but the real save() is never tested
34 // This is fine for unit tests, but integration tests must cover the real DB path
35});Practical guideline: 80% branch coverage is a floor, not a target. Critical paths (auth, payments, data validation) should be at 95%+. Non-critical utilities can be at 80%. Chasing 100% everywhere produces tests for getters and setters that add noise without value.
Coverage output is most useful as a to-do list. After running jest --coverage, sort by branch coverage to find the most undertested files:
1# Terminal output example:
2#
3# File | % Stmts | % Branch | % Funcs | % Lines
4# ------------------------------|---------|----------|---------|--------
5# src/auth/auth.service.ts | 95.2 | 88.4 | 100.0 | 95.2
6# src/payment/payment.service.ts| 72.1 | 55.0 | 80.0 | 72.1 ← priority
7# src/cart/cart.utils.ts | 100.0 | 93.7 | 100.0 | 100.0
8# src/email/email.service.ts | 45.2 | 30.0 | 60.0 | 45.2 ← critical gap
9
10# email.service.ts at 30% branch coverage — open the HTML report:
11# open coverage/lcov-report/src/email/email.service.ts.html1// Workflow for closing coverage gaps:
2// 1. Open HTML report for the file with lowest branch coverage
3// 2. Find the red/yellow annotated branches
4// 3. Write the simplest test that exercises that branch
5// 4. Re-run coverage to confirm the branch turns green
6
7// Example: discovered uncovered branch in email-service.ts
8// The 'else' branch of: if (process.env['EMAIL_ENABLED'] !== 'true') { return; }
9
10// Gap test:
11test('skips sending when EMAIL_ENABLED is not true', async () => {
12 const original = process.env['EMAIL_ENABLED'];
13 process.env['EMAIL_ENABLED'] = 'false';
14
15 const sendSpy = jest.spyOn(smtpTransport, 'send');
16 await sendWelcomeEmail('[email protected]');
17
18 expect(sendSpy).not.toHaveBeenCalled();
19
20 process.env['EMAIL_ENABLED'] = original; // restore
21});A coupon validation engine with multiple branches, tested to 100% branch coverage using Jest's coverage tools to identify and close every gap.
1// src/coupon-engine.ts
2export type CouponType = 'percentage' | 'fixed' | 'free-shipping';
3
4export interface Coupon {
5 code: string;
6 type: CouponType;
7 value: number;
8 minOrderAmount: number;
9 expiresAt: Date;
10 usageLimit: number;
11 usageCount: number;
12}
13
14export interface ApplyResult {
15 discount: number;
16 finalAmount: number;
17 message: string;
18}
19
20export function applyCoupon(
21 coupon: Coupon,
22 orderAmount: number,
23 shippingCost: number
24): ApplyResult {
25 // Branch 1: expiry check
26 if (new Date() > coupon.expiresAt) {
27 throw new Error(`Coupon ${coupon.code} has expired`);
28 }
29
30 // Branch 2: usage limit check
31 if (coupon.usageCount >= coupon.usageLimit) {
32 throw new Error(`Coupon ${coupon.code} has reached its usage limit`);
33 }
34
35 // Branch 3: minimum order check
36 if (orderAmount < coupon.minOrderAmount) {
37 throw new Error(
38 `Order amount must be at least $${coupon.minOrderAmount} to use this coupon`
39 );
40 }
41
42 // Branch 4: coupon type switch
43 let discount = 0;
44 switch (coupon.type) {
45 case 'percentage':
46 discount = orderAmount * (coupon.value / 100);
47 break;
48 case 'fixed':
49 discount = Math.min(coupon.value, orderAmount); // cannot discount more than order
50 break;
51 case 'free-shipping':
52 discount = shippingCost;
53 break;
54 }
55
56 return {
57 discount,
58 finalAmount: orderAmount + shippingCost - discount,
59 message: `Coupon ${coupon.code} applied: -$${discount.toFixed(2)}`,
60 };
61}
62
63// src/coupon-engine.test.ts
64import { applyCoupon, Coupon } from './coupon-engine';
65
66const FUTURE = new Date(Date.now() + 86_400_000); // tomorrow
67const PAST = new Date(Date.now() - 86_400_000); // yesterday
68
69function buildCoupon(overrides: Partial<Coupon> = {}): Coupon {
70 return {
71 code: 'TEST10',
72 type: 'percentage',
73 value: 10,
74 minOrderAmount: 0,
75 expiresAt: FUTURE,
76 usageLimit: 100,
77 usageCount: 0,
78 ...overrides,
79 };
80}
81
82describe('applyCoupon', () => {
83 describe('expiry validation', () => {
84 it('throws when coupon is expired', () => {
85 const expired = buildCoupon({ expiresAt: PAST });
86 expect(() => applyCoupon(expired, 100, 10)).toThrow('has expired');
87 });
88
89 it('does not throw for a future expiry date', () => {
90 const valid = buildCoupon({ expiresAt: FUTURE });
91 expect(() => applyCoupon(valid, 100, 10)).not.toThrow();
92 });
93 });
94
95 describe('usage limit validation', () => {
96 it('throws when usage count equals the limit', () => {
97 const used = buildCoupon({ usageLimit: 5, usageCount: 5 });
98 expect(() => applyCoupon(used, 100, 10)).toThrow('usage limit');
99 });
100
101 it('allows use when count is below the limit', () => {
102 const partial = buildCoupon({ usageLimit: 5, usageCount: 4 });
103 expect(() => applyCoupon(partial, 100, 10)).not.toThrow();
104 });
105 });
106
107 describe('minimum order validation', () => {
108 it('throws when order amount is below the minimum', () => {
109 const highMin = buildCoupon({ minOrderAmount: 50 });
110 expect(() => applyCoupon(highMin, 30, 10)).toThrow('at least $50');
111 });
112
113 it('allows orders exactly at the minimum', () => {
114 const exactMin = buildCoupon({ minOrderAmount: 50 });
115 expect(() => applyCoupon(exactMin, 50, 10)).not.toThrow();
116 });
117 });
118
119 describe('percentage coupons', () => {
120 it('calculates percentage discount correctly', () => {
121 const coupon = buildCoupon({ type: 'percentage', value: 20 });
122 const result = applyCoupon(coupon, 100, 10);
123 expect(result.discount).toBe(20);
124 expect(result.finalAmount).toBe(90); // 100 + 10 - 20
125 });
126 });
127
128 describe('fixed coupons', () => {
129 it('applies fixed discount', () => {
130 const coupon = buildCoupon({ type: 'fixed', value: 15 });
131 const result = applyCoupon(coupon, 100, 10);
132 expect(result.discount).toBe(15);
133 });
134
135 it('caps fixed discount at the order amount', () => {
136 const coupon = buildCoupon({ type: 'fixed', value: 200 });
137 const result = applyCoupon(coupon, 50, 10);
138 expect(result.discount).toBe(50); // capped at order amount
139 });
140 });
141
142 describe('free-shipping coupons', () => {
143 it('discounts the full shipping cost', () => {
144 const coupon = buildCoupon({ type: 'free-shipping', value: 0 });
145 const result = applyCoupon(coupon, 100, 9.99);
146 expect(result.discount).toBe(9.99);
147 expect(result.finalAmount).toBeCloseTo(100, 2);
148 });
149 });
150
151 describe('result message', () => {
152 it('includes the coupon code and discount amount in the message', () => {
153 const coupon = buildCoupon({ code: 'SAVE20', type: 'fixed', value: 20 });
154 const result = applyCoupon(coupon, 100, 10);
155 expect(result.message).toContain('SAVE20');
156 expect(result.message).toContain('-$20.00');
157 });
158 });
159});Every branch in applyCoupon is covered: both sides of the expiry check, both sides of the usage limit check, both sides of the minimum order check, all three switch cases, and the Math.min cap for fixed coupons. Run npx jest --coverage — you should see 100% branches for coupon-engine.ts. The buildCoupon factory keeps each test focused on one scenario by overriding only the relevant property.
The next lesson covers snapshot testing: when to use jest.toMatchSnapshot() for React components and serialised data, how to update snapshots correctly, and how to avoid the snapshot anti-pattern of approving changes without reviewing them.
Your progress is saved in this browser, on this device only.