Code coverage is the most misunderstood metric in software quality. Teams argue about whether 80% is enough, whether 100% is achievable, whether coverage means anything at all. The metric is genuinely useful — uncovered code is definitely untested — but it's also easy to game and dangerous to over-trust. This lesson covers how to measure it, how to publish it in CI, how to use it as a gate, and how to read it without fooling yourself.
What code coverage measures
A coverage tool instruments your source code — inserting probes at each line, branch, and method — and reports which of those probes were triggered during a test run. The output is a percentage: of the N executable points in your code, X% were executed at least once.
Line coverage (also called statement coverage): the percentage of code lines executed. A line either ran or it didn't.
Branch coverage: the percentage of conditional branches taken. An if/else has two branches. If your tests only exercise the if path, branch coverage is 50% even if line coverage is 100% (because both lines ran, just not both branches of the condition).
Method coverage: the percentage of methods called at least once. Useful for identifying dead code.
Class coverage: the percentage of classes instantiated at least once.
Branch coverage is the most meaningful for finding untested logic. Line coverage is the most widely cited because it's the easiest to understand. Both are useful; neither is sufficient alone.
JaCoCo for Java
JaCoCo (Java Code Coverage) is the standard for Maven and Gradle projects. It requires no test code changes — just a plugin configuration:
<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>mvn test now: instruments the code, runs tests, generates an HTML report at target/site/jacoco/index.html, and fails the build if line coverage drops below 80%. The check execution is the quality gate — remove it if you want reporting without enforcement.
Publish the coverage report in CI:
- run: mvn test -B
- uses: actions/upload-artifact@v4
if: always()
with:
name: jacoco-report
path: target/site/jacoco/
retention-days: 14Istanbul / V8 for JavaScript and TypeScript
Jest, Vitest, and Node.js projects use Istanbul (integrated into Jest) or V8 coverage (built into Node). No additional dependency is needed for Jest:
npx jest --coverage// jest.config.js
{
"collectCoverage": true,
"coverageDirectory": "coverage",
"coverageThresholds": {
"global": {
"lines": 80,
"branches": 70,
"functions": 80,
"statements": 80
}
}
}When thresholds are configured, jest --coverage fails with a non-zero exit code if any threshold is not met — feeding directly into CI's fail-fast logic.
For Vitest:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: { lines: 80, branches: 70 }
}
}
})Codecov — coverage diffs on PRs
Codecov is a free SaaS service that reads coverage reports from CI and posts a diff comment on every PR showing which lines were added without test coverage:
- run: npx jest --coverage --coverageReporters=lcov
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/lcov.info
fail_ci_if_error: trueThe PR comment shows: overall coverage change (+0.3% or -1.2%), which new lines are uncovered (highlighted in red), and whether the PR improves or worsens coverage. For teams that want to prevent coverage regression without requiring 80% from day one, Codecov's "patch coverage" mode fails the check only if new lines added by the PR are uncovered — a gentler gate than a global threshold.
What good coverage numbers look like
Coverage thresholds: where teams typically set the bar
Excluding generated code, framework boilerplate, and trivial getters from coverage calculation is important — including them inflates the number without meaningful signal. JaCoCo uses @Generated annotations and configuration blocks:
<configuration>
<excludes>
<exclude>**/generated/**</exclude>
<exclude>**/*Dto.class</exclude>
<exclude>**/config/**</exclude>
</excludes>
</configuration>The critical caveat: coverage is execution, not correctness
A test can achieve 100% line coverage while verifying nothing:
@Test
public void testCheckout() {
checkout.processOrder(order); // line executed — covered
// no assertions
}This test covers the processOrder method completely. It catches compile errors and exceptions. It does not verify that the order total is calculated correctly, that inventory is decremented, or that a confirmation email is queued. Coverage says "this code ran." It does not say "this code was verified."
The implication: coverage is a useful lower bound. Code with 0% coverage is definitely untested. Code with 85% coverage might still have critical business paths with no assertions. Use coverage to find untested areas, not to conclude that covered areas are correct.
⚠️ Common mistakes
- Setting coverage thresholds at 100%. Teams that set 100% coverage requirements typically achieve it by writing tests with no assertions or by excluding everything that's hard to cover. Both outcomes are worse than a lower threshold with meaningful assertions. 80% with real assertions is worth more than 100% with empty tests.
- Measuring code coverage for E2E tests and calling it "test coverage." E2E tests exercising a checkout flow will produce high code coverage for the entire checkout path — but that coverage is fragile, slow to run, and doesn't tell you which specific logical branches are tested. E2E coverage measurement conflates execution with verification in a particularly misleading way. Measure unit and integration test coverage separately.
- Never looking at the actual coverage report. A green coverage badge on a README is useful context. The actual report — which uncovered methods exist, which untested branches lurk in business-critical code — is where the value is. Schedule a quarterly coverage review where the team reads the uncovered section list and decides whether to add tests or accept the gap.
🎯 Practice task
Add coverage measurement and a gate to your project — 35 minutes.
- Java/Maven: add JaCoCo to
pom.xmlwith both thereportandcheckexecutions. Setminimumto0.70(70%). Runmvn test. Read the HTML report attarget/site/jacoco/index.html. Find the class with the lowest coverage. - JavaScript: add
--coverageto your Jest or Vitest command. Add acoverageThresholdsconfig withlines: 70. Run. Confirm the command fails if coverage drops below threshold. - Add an artifact upload for the coverage report to your GitHub Actions workflow. Push a PR. Download and open the report.
- Stretch — Codecov: sign up at codecov.io, add
CODECOV_TOKENas a repository secret, add thecodecov/codecov-actionstep after your test step. Open a PR with a new uncovered function. Read the Codecov PR comment.
The next lesson completes Chapter 5: notifications that reach the right people at the right time, and the status badges that give instant visibility on the project's health.