Test coverage strategy
Help agents trade off “the number” vs risk: gate thresholds, exclusion lists, line/branch/patch reports, and what to add when CI fails—coverage is a signal, not a score to game.
The SKILL should state line vs branch vs changed-line coverage, default exclusions for generated code, type declarations, and config-only files to avoid meaningless points; critical modules may require a higher patch floor than the repo-wide gate, aligned with Codecov carryforward or equivalent.
When the gate fails, agents should output the business meaning of uncovered branches and suggested test types (unit / integration / E2E), not only file percentages; reports must point to concrete lines in CI logs.
Line vs branch coverage
Line coverage answers “was this line executed?”; branch coverage asks “did every condition outcome run?” Logic-heavy modules with enums and error paths care more about branches; pipeline- or template-heavy code may start from lines plus critical paths.
// Same function, three coverage metrics compared:
function getDiscount(userLevel: string, amount: number): number {
if (userLevel === 'vip') { // Branch A
if (amount >= 100) return 0.2; // Branch B (nested)
return 0.1; // Branch C
}
return 0; // Branch D
}
// Scenario 1: only one test getDiscount('vip', 150) → returns 0.2
// Line coverage: 75% (line "return 0.1" and "return 0" never executed)
// Branch coverage: 50% (covered A-true + B-true; A-false and B-false missed)
// Path coverage: 25% (4 total paths, only A-true→B-true covered)
// Scenario 2: three tests: ('vip',150), ('vip',50), ('normal',0)
// Line coverage: 100% (every line executed)
// Branch coverage: 100% (A-true/false, B-true/false all covered)
// Path coverage: 75% (3/4 paths; missing A-true→B-false i.e. amount exactly 100)
// Jest/Vitest coverage configuration (vitest.config.ts / jest.config.ts):
export default defineConfig({
test: {
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'lcov', 'html'],
// Global thresholds (CI fails below these)
thresholds: {
global: {
lines: 80,
branches: 70,
functions: 80,
statements: 80,
},
// Higher thresholds for specific modules
'src/billing/**': {
lines: 95,
branches: 90,
},
},
// Exclude generated code and type declarations
exclude: [
'src/**/*.d.ts',
'src/**/*.generated.ts',
'src/mocks/**',
'src/**/__tests__/**',
],
},
},
});
Effective coverage vs ineffective coverage:
// ❌ Ineffective coverage: test exists but assertions are meaningless
it('should call processPayment', () => {
const spy = jest.spyOn(paymentService, 'processPayment');
checkout.submit(orderData);
expect(spy).toHaveBeenCalled(); // ← only asserts "was called", not the result
// Coverage 100% but the test proves nothing
});
// ✅ Effective coverage: asserting observable behavior
it('should return orderId after successful payment', async () => {
jest.spyOn(paymentService, 'processPayment').mockResolvedValue({ success: true });
const result = await checkout.submit(orderData);
expect(result.orderId).toMatch(/^ORD-/); // ← assert business result
expect(result.status).toBe('confirmed'); // ← assert status
});
it('should throw PaymentError when card is declined', async () => {
jest.spyOn(paymentService, 'processPayment').mockRejectedValue(
new PaymentError('CARD_DECLINED')
);
await expect(checkout.submit(orderData)).rejects.toThrow('CARD_DECLINED');
// ← asserting error path (this is where branch coverage adds value)
});
Appropriate uses of istanbul ignore:
// ✅ Legitimate uses of istanbul ignore
// 1. Debug code (should not be tested in production)
/* istanbul ignore next */
if (process.env.DEBUG_MODE) {
console.log('Debug info:', state);
}
// 2. Platform-specific code (CI only runs on Linux)
/* istanbul ignore next */
if (process.platform === 'win32') {
return path.win32.join(...parts);
}
// 3. Unreachable defensive code
function exhaustiveCheck(value: never): never {
/* istanbul ignore next */
throw new Error(`Unhandled case: ${value}`);
}
// ❌ Misuse (hiding real debt)
/* istanbul ignore next */
async function processRefund(orderId: string) {
// Complex business logic here — should NOT be ignored
const order = await Order.findById(orderId);
}
Incremental coverage strategy for legacy code:
# Require coverage only for new/changed code (diff), not legacy code
# Codecov configuration (codecov.yml):
coverage:
precision: 2
round: down
range: "70...100"
status:
patch: # Only check code changed in this PR
default:
target: 80% # New code must reach 80%
threshold: 5%
project: # Overall coverage must not drop more than 2%
default:
target: auto
threshold: 2%
# In CI: report coverage only for new/changed code (nyc / c8)
# package.json scripts:
"test:coverage:diff": "nyc --include='$(git diff --name-only HEAD~1 | grep \.ts$ | tr '\n' ',')' npm test"
Current focus (affects planner defaults)
Lines first
Gate on lines / statements; branches can be advisory or a second threshold. Good for fast baselines and less friction on legacy code.
Branches first
Gate explicitly includes branches; higher bar for if, switch, optional chaining, and error paths. Fits payments, auth, compliance packages.
Both
Declare line and branch floors at repo or package level; use PR diff coverage so “new changes” meet both. Document which metric blocks merge vs trend-only.
Toggling focus does not replace team agreement: write “primary + secondary + blocking vs advisory” into the SKILL so generated config matches Istanbul, c8, JaCoCo, Coverage.py field names.
From collection to gate
[ Unit / integration / E2E runs ]
│
▼
┌─────────────┐ emit lcov / cobertura / jacoco.xml
│ Collector │──── source maps, merge parallel jobs
└─────────────┘
│
▼
┌─────────────┐ filter: generated, declarations, config-only
│ Normalize & │──── versioned exclude list + code review
│ exclude │
└─────────────┘
│
▼
┌─────────────┐ repo gate + package/patch gate + carryforward
│ CI gate │──── fail → link to missed lines + test suggestions
└─────────────┘
When configuring pipelines, keep reports reproducible for the same commit: fixed seeds where relevant, clean old artifacts, upload one merged coverage file—avoid jobs overwriting each other.
Exclusions and review
- Every exclusion must be explainable in review; ban blanket
/* istanbul ignore */to hide debt. - Layer global vs package settings so one-size thresholds do not stall delivery.
- Prefer excluding generated dirs (protobuf, OpenAPI, routers) in tool config, not sprinkling ignores in business files.
What to write when the gate fails
Suggested structure: ① which metric failed—lines, branches, or patch; ② business scenario (user-visible behavior, error codes, edge cases); ③ recommended test layer and minimal shape (table-driven, contract, E2E smoke).
If the log only says “coverage 78%” with no file or line, humans and agents cannot add tests efficiently; CI logs or bot comments should link HTML reports or source anchors.
Layered thresholds and diff coverage
- Repo-wide gate: protect baseline; historical debt may exist but must not worsen.
- Package / module: raise floors for
core,billing, etc., or require branch metrics. - PR diff / patch: new code meets a higher bar; align with Codecov patch and document carryforward behavior.
Threshold planner
Drag sliders to produce bullet points you can paste into a SKILL or team doc; switching line / branch / both adjusts metric names and relative strictness in the text.
Numbers need team review; replace tool-specific keys (GitHub Actions, Codecov, Sonar, etc.) with your repo’s real config when you land this.
---
name: test-coverage
description: Coverage configuration, line/branch/path differences, effective coverage, and legacy incremental strategy
---
# Step 1: choose coverage metric
line: fast baseline, suitable for legacy code (Istanbul lines/statements)
branch: required for logic-heavy modules (Istanbul branches)
path: manually supplement edge cases for high-risk modules
# Step 2: configure Jest/Vitest thresholds (vitest.config.ts)
thresholds:
global: { lines: 80, branches: 70, functions: 80 }
'src/billing/**': { lines: 95, branches: 90 }
exclude: ['**/*.d.ts', '**/*.generated.ts', 'src/mocks/**']
# Step 3: distinguish effective vs ineffective coverage
effective: expect(result.orderId).toMatch(/^ORD-/) ← assert business result
ineffective: expect(spy).toHaveBeenCalled() ← only verifies call, no result assertion
# Step 4: istanbul ignore only for
valid cases: DEBUG_MODE branches / platform-specific code / exhaustiveCheck
forbidden: hiding untested branches in complex business logic
# Step 5: incremental strategy for legacy code
Codecov patch config:
status.patch.default.target: 80% # 80% coverage for new code
status.project.default.threshold: 2% # overall cannot drop more than 2%
# Step 6: output format when gate fails
① metric type: "branches coverage 65%, below threshold 70%"
② specific location: "src/billing/refund.ts:87-92 (refund timeout branch)"
③ business meaning: "refund timeout does not trigger an alert, may leave user funds stuck"
④ suggested test: "add a mock-timeout integration test in refund.test.ts"