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.

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.

Thresholds (%)

Branches are harder to max: default a few points below line; when branch-first, move delta toward 0.

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"

Back to skills More skills