CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/test-coverage-targeter

Builds a "what to test next" recommendation by combining a coverage report (LCOV / Cobertura / coverage.py JSON / Jest JSON / JaCoCo XML) with the PR's `git diff`, ranking uncovered branches by risk × cost - risk weighted by McCabe cyclomatic complexity and code-churn frequency, cost weighted by the unit-test pyramid layer (unit tests cheaper than integration than E2E). Also carries the coverage debt ledger: a weekly per-file drift report over N historical main runs flagging `falling` (line% slid >M pp from peak), `stale` (flat coverage + high churn), and `orphan` (lost last covering test) files, whose rows feed the same targeting. Emits a prioritized list with concrete file:line targets and the test layer recommended for each. Use when a team has the budget to write 5 - 10 new tests and needs help picking which uncovered code to target first instead of blindly chasing 100% coverage, or when specific modules are eroding silently while whole-repo coverage looks fine.

76

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files
name:
test-coverage-targeter
description:
Builds a "what to test next" recommendation by combining a coverage report (LCOV / Cobertura / coverage.py JSON / Jest JSON / JaCoCo XML) with the PR's `git diff`, ranking uncovered branches by risk × cost - risk weighted by McCabe cyclomatic complexity and code-churn frequency, cost weighted by the unit-test pyramid layer (unit tests cheaper than integration than E2E). Also carries the coverage debt ledger: a weekly per-file drift report over N historical main runs flagging `falling` (line% slid >M pp from peak), `stale` (flat coverage + high churn), and `orphan` (lost last covering test) files, whose rows feed the same targeting. Emits a prioritized list with concrete file:line targets and the test layer recommended for each. Use when a team has the budget to write 5 - 10 new tests and needs help picking which uncovered code to target first instead of blindly chasing 100% coverage, or when specific modules are eroding silently while whole-repo coverage looks fine.

test-coverage-targeter

Overview

100% coverage isn't the goal; risk-adjusted coverage is. A 50-line uncovered helper that hasn't been changed in 4 years is lower priority than a 10-line uncovered branch on a payment-handling function that gets edited every week.

This skill builds a ranked list of uncovered branches, scoring each by:

  • Risk weight - McCabe cyclomatic complexity (cyclomatic)
    • git churn frequency (commits in last N days) + PR-touch (was the file changed in this PR?).
  • Cost weight - Per the test pyramid (test-pyramid), unit tests are cheap, integration tests are medium, UI tests are expensive. The recommendation includes the layer.

The output is 5 - 10 specific test targets the team can take in one PR - not a 200-line "everything uncovered" dump.

When to use

  • A coverage report shows 60 - 80% - adding 5 tests should target the highest-impact uncovered code, not random low-hanging fruit.
  • A PR's coverage gate is failing on new files; the team needs to know which uncovered branches actually matter.
  • A test-debt sprint is upcoming and the team needs a 10-test budget allocated to the right places.
  • Pair with coverage-diff-reporter for the "what regressed" + "what to add" combo.

Step 1 - Inputs

Two required + two optional inputs:

InputRequiredSource
Coverage reportLCOV / Cobertura / Jest JSON / coverage.py JSON / JaCoCo XML
Source treeProject root checkout
git log historyoptFor churn weighting (Step 3).
PR diffoptFor PR-touch weighting (Step 4).

Step 2 - Extract uncovered branches

The shape (after parsing per upstream parser):

{
  'path': 'src/checkout/cart.ts',
  'uncovered_branches': [
    { 'line': 42, 'condition': 'if (item.stock < quantity)', 'arms_uncovered': 1, 'arms_total': 2 },
    { 'line': 78, 'condition': 'switch (status)', 'arms_uncovered': 3, 'arms_total': 5 },
  ],
  'uncovered_lines': [33, 34, 35, 92, 93],
  'covered_pct': 65.4,
}

Per language tool:

  • LCOV: parse BRDA:<line>,<block>,<branch>,<taken> records; taken == '-' is uncovered. Use lcov-analysis.
  • Cobertura: parse <line branch="true" condition-coverage="50% (1/2)"/>; use the Cobertura reference in lcov-analysis.
  • Jest JSON b field: arrays of arm hit counts; uncovered = 0. Use js-unit-tests (qa-unit-tests-js).
  • JaCoCo XML: parse <counter type="BRANCH" missed="N" covered="M"/> per method, then read source lines for context. Use jacoco-analysis.

Step 3 - Compute the risk weight

def risk_weight(file_path, branch):
    cyclomatic = mccabe_complexity(file_path, branch.line)
    churn      = git_churn(file_path, days=90)
    return (
        normalize(cyclomatic, 1, 20) * 0.5     # 50% complexity weight
        + normalize(churn,    0, 50) * 0.5     # 50% churn weight
    )

McCabe cyclomatic complexity

Per cyclomatic: McCabe's formula is M = E - N + 2 (edges − nodes + 2). The threshold convention:

"1 - 10: Simple procedure, little risk" "11 - 20: More complex, moderate risk" "21 - 50: Complex, high risk" ">50: Untestable code, very high risk" (cyclomatic)

Compute via per-language tools:

# Python
radon cc src/ -a

# JavaScript / TypeScript
npx complexity-report src/ -f json

# Java
# JaCoCo's COMPLEXITY counter (already in jacoco.xml per jacoco-analysis)

Function with cyclomatic >10 + uncovered branch = strong target.

Git churn

# Commits in the last 90 days touching this file
git log --since='90 days ago' --format= -- src/checkout/cart.ts | wc -l

High-churn files are where bugs accumulate; uncovered branches in high-churn files are the highest-priority targets.

Per cyclomatic: "reducing the cyclomatic complexity of code is not proven to reduce the number of errors or bugs in that code." Use complexity as a risk indicator, not a goal.

Step 4 - PR-touch boost

If the PR diff (Step 1 optional input) changed a file, boost its risk weight by 1.5×. Newly-edited code is strictly higher regression risk than untouched code.

def pr_boost(file_path, pr_changed_files):
    return 1.5 if file_path in pr_changed_files else 1.0

final_risk = risk_weight(...) * pr_boost(...)

Step 5 - Cost weight (test pyramid layer)

Per test-pyramid, the canonical layers (Cohn 2009):

LayerCost (relative)Recommend for
UnitPure functions, business-logic branches.
Service / APICross-module integration, controllers, repos.
UI / E2E10×User flows, browser interactions.

Per test-pyramid: "you should have many more low-level UnitTests than high level BroadStackTests running through a GUI ... UI tests are brittle, expensive to write, and time consuming to run."

The targeter classifies each candidate branch by file path heuristic:

def layer(path):
    if any(s in path for s in ['/components/', '/views/', '/pages/', '/e2e/']):
        return 'ui-or-e2e'
    if any(s in path for s in ['/api/', '/routes/', '/controllers/', '/services/']):
        return 'service'
    return 'unit'

Step 6 - Score and rank

COST_BY_LAYER = {'unit': 1, 'service': 3, 'ui-or-e2e': 10}

for candidate in candidates:
    candidate['score'] = candidate['risk'] / COST_BY_LAYER[candidate['layer']]

candidates.sort(key=lambda c: c['score'], reverse=True)

Score = risk / cost. A unit-layer branch with risk 0.6 (score 0.60) beats a UI-layer branch with risk 0.9 (score 0.09).

Take the top 5 - 10 candidates (configurable). More than 10 is a to-do list, not a recommendation.

Step 7 - Render

## Uncovered branches - recommended targets (top 7)

This PR adds 12 uncovered branches in code touched by the PR. The
prioritized list focuses test budget on high-risk × low-cost
targets per the [test pyramid][tp].

| # | File                                | Line | Branch                              | Layer | Risk | Recommendation |
|---|-------------------------------------|-----:|-------------------------------------|-------|-----:|----------------|
| 1 | `src/checkout/promo.ts`              |  42 | `if (codeIsValid && !expired)`       | unit  | 0.91 | Add a unit test for the expired-code path. |
| 2 | `src/checkout/promo.ts`              |  78 | `switch (codeType)` arm `'BOGO'`     | unit  | 0.85 | Add a unit test per arm; BOGO arm uncovered. |
| 3 | `src/api/payments.ts`                | 134 | `if (provider === 'stripe')`         | service | 0.82 | Add an integration test for the stripe path. |
| 4 | `src/checkout/cart.ts`               |  21 | `if (item.stock < quantity)`         | unit  | 0.78 | Add a unit test for stock-shortfall edge case. |
| 5 | `src/api/payments.ts`                | 200 | `try / catch` (catch arm)            | service | 0.65 | Add a test that triggers the failure path. |
| 6 | `src/components/CheckoutModal.tsx`    |  88 | `if (showPromoBanner)`                | unit  | 0.55 | Add a snapshot/component test for the banner-hidden case. |
| 7 | `src/api/orders.ts`                  | 156 | `if (status === 'fulfilled')`        | service | 0.48 | Add an integration test for the fulfilled-status path. |

### Skipped (low score)

5 candidates skipped - see `coverage-targets.json` for the full
list. Highest-risk skipped: UI-layer test that would catch a 0.9
risk branch but cost 10× a unit test (score 0.09).

### Approach

For each target above, the recommended test shape:

- **Unit (5)**: Add a per-branch test in the existing `*.spec.ts`;
  use `expect.fail` if no path triggers the branch yet.
- **Service (2)**: Use `testcontainers`
  to bring up a real Postgres + the Stripe sandbox; assert the
  branch outcome.

Step 8 - Wire into CI as advisory

This skill is advisory, not gating. Post the recommendation as a PR comment via coverage-diff-reporter's sticky-comment mechanism (see coverage-diff-reporter Step 7) but don't fail the build on it:

- name: Generate coverage targets
  if: github.event_name == 'pull_request'
  run: |
    python scripts/coverage_targets.py \
      --coverage current.json \
      --diff <(git diff --name-only origin/${{ github.base_ref }}...HEAD) \
      --top 7 \
      > targets.md

- name: Post sticky comment
  uses: marocchino/sticky-pull-request-comment@v2
  with:
    header: coverage-targets
    path: targets.md

Coverage debt ledger (drift over time)

Aggregate coverage hides per-file decay: a repo can sit at 82% overall while the payment module slides from 95% to 60% across 30 PRs, none of which crossed a gate threshold. The debt ledger is the weekly, informational (never gating) companion to the per-PR targeting above - it walks a rolling window of persisted coverage history and flags files on three axes:

AxisSignal
Fallingline% (or branch%) dropped >M pp (default 5) from its window peak.
StaleCoverage flat (<1pp variance) while churn is high (≥10 commits / 90 days).
OrphanLost its last covering test (every covering test was deleted).

Mechanics:

  1. Persist history: each main-branch run uploads its parsed coverage as coverage-history/<sha>-<timestamp>.json ({sha, timestamp, files: [{path, line_pct, branch_pct}]}); ~90 days retention is enough to catch quarterly drift.
  2. Detect falling via peak-vs-now, not last-vs-now - a sequence of small drops (-1pp, -1pp, -1pp...) never crosses a per-run gate but adds up:
peak = max(pct for _, _, pct in series)      # over the last ~30 runs
drop = peak - series[-1][2]
if drop >= 5.0:  # FALL_THRESHOLD_PP
    flag('falling', path, peak, drop)
  1. Detect stale by pairing coverage variance <1pp with git log --since='90 days ago' -- <path> churn ≥10 (checkout with fetch-depth: 0 or every file looks low-churn).
  2. Detect orphans from the per-test → source map (see regression-suite-selector in qa-test-impact-analysis): a file whose covering tests were all deleted is at 0% now, however fine the aggregate looks.
  3. Render one stack-ranked backlog, highest-risk first - orphan → falling (>10pp) → falling (5-10pp) → stale - showing absolute % alongside the drop (100%→95% is less urgent than 60%→55%). Run it weekly on a schedule and refresh one GitHub issue so debt history stays visible; never per-PR (that conflates the gate's job with the ledger's job) and never gating (refactors that remove dead code legitimately "drop" coverage).

Each ledger row then feeds the targeting workflow above: the falling / orphan file becomes the Step 1 input, and Steps 2-7 turn it into specific file:line test targets.

Anti-patterns

Anti-patternWhy it failsFix
Recommending coverage targets without considering test layerSuggests UI tests for branches that should be unit-tested; cost ignored.Score = risk / cost (Step 6).
Listing every uncovered branch200-row list; team ignores.Top 5 - 10 (Step 6).
Pure-coverage chase (every uncovered line is a target)Hits 100% by writing low-value tests; signal-to-noise collapses.Risk-weight by complexity + churn (Step 3).
Treating cyclomatic complexity as a goalPer cyclomatic: reducing complexity isn't proven to reduce defects.Use complexity as a risk indicator only (Step 3).
Ignoring PR-changed filesA new function added in the PR with 0 coverage isn't surfaced.PR-touch boost (Step 4).
Recommending tests for code marked # pragma: no cover / istanbul ignoreThe team explicitly excluded that code.Skip files / lines with explicit ignores.
Blocking the build on the recommendationRecommendation is opinion; gating turns it into bureaucracy.Advisory comment only (Step 8); the actual gate is lcov-analysis (LCOV or Cobertura).

Limitations

  • Cyclomatic complexity is one signal, not the truth. Per cyclomatic: "captures only one aspect of software, so relying on it alone may provide an incomplete representation." Pair with churn for a better signal.
  • Layer heuristic is path-based. A services/ directory might contain pure functions (unit) and DB-touching code (integration). Tune the heuristic per project.
  • No semantic understanding of "what the branch does". The targeter says "branch at line 42 needs a test"; it doesn't say "the test should assert that expired codes return 400".
  • Churn requires git log. Shallow CI clones (default actions/checkout with no fetch-depth) lack the history; fetch-depth: 0 is needed.
  • No long-term memory. A target ignored three PRs in a row keeps reappearing; consider persisting a "snoozed" list in a coverage-targets.snoozed.json checked into the repo.

References

  • test-pyramid - Mike Cohn's pyramid (2009), three layers (unit / service / UI), "many more low-level UnitTests than high level BroadStackTests" rationale (UI tests "brittle, expensive to write, and time consuming to run").
  • cyclomatic - McCabe (1976) cyclomatic complexity formula M = E - N + 2, threshold convention (1 - 10 / 11 - 20 / 21 - 50 /

    50), the warning that "reducing the cyclomatic complexity of code is not proven to reduce the number of errors or bugs".

  • lcov-analysis (LCOV + Cobertura), jacoco-analysis, coverage-py-analysis, and js-unit-tests (qa-unit-tests-js) for Jest/Vitest - upstream parsers this skill consumes.
  • coverage-diff-reporter - sibling reporter; the diff identifies what regressed, the targeter identifies what to add next.
Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/test-coverage-targeter badge