How do you enforce a minimum coverage threshold in the build so a drop fails CI, and how do you exclude code that shouldn't count?
answer
- Gradle: jacocoTestCoverageVerification + violationRules; wire check.dependsOn
- Maven: check goal in verify phase with <rules>/<limit>
- Rule = scope (BUNDLE/PACKAGE/CLASS/METHOD) x counter x minimum ratio
- Exclude generated/DTO/main via class-name globs — not over-exclude
- Bundle average hides new untested classes → use per-class or new-code gates
basics
~20 sJaCoCo has a verification step that fails the build if coverage falls below a number you set. In Gradle it's jacocoTestCoverageVerification; in Maven it's the check goal with rules. You can also exclude generated or boilerplate code (DTOs, generated sources) so it doesn't drag the number down.
solid answer
~50 sBoth build tools expose a **verification** task that asserts coverage rules and fails the build when violated, so a regression blocks the merge. In **Gradle**, `jacocoTestCoverageVerification` takes `violationRules` with `limit { counter; value; minimum }` — e.g. require BRANCH ratio ≥ 0.80 — and you wire it via `check.dependsOn`. In **Maven**, the `jacoco-maven-plugin`'s **`check`** goal (bound to `verify`) holds `<rules>` with a `<limit>` per counter. Rules can target different **scopes** (BUNDLE, PACKAGE, CLASS, METHOD) and counters (INSTRUCTION, BRANCH, LINE, COMPLEXITY). For **exclusions**, you filter out code that shouldn't count — generated sources, DTOs, `main`/config classes — via the report/check `excludes` patterns (class-name globs like `**/dto/**`, `**/*Generated*`). Best practice: gate on **branch or instruction** at a realistic minimum, exclude only truly untestable/generated code, and consider a *per-class* or *changed-code* rule rather than a single bundle number, since a bundle average lets new untested classes hide behind old well-tested ones.
code
java · 15 lines// build.gradle — fail CI if branch coverage drops, with exclusions
jacocoTestCoverageVerification {
violationRules {
rule { // whole-module floor
limit { counter = 'BRANCH'; value = 'COVEREDRATIO'; minimum = 0.80 }
}
rule { // per-class floor catches new untested classes
element = 'CLASS'
excludes = ['**.generated.**', '**.*Dto', '**.*Application']
limit { counter = 'LINE'; minimum = 0.50 }
}
}
}
check.dependsOn jacocoTestCoverageVerification
// Now `./gradlew check` (and CI) fails on a coverage regression.go deeper
Knows a threshold task exists that can fail the build, and that you can exclude generated code.
Configures a Gradle violationRule or Maven check rule with a counter and minimum, wires it into check/verify, and excludes DTOs/generated classes.
Chooses scope+counter deliberately, adds per-class floors, recognizes the bundle-average trap, and avoids over-exclusion; gets exclusion glob semantics right.
Defines org coverage policy around new-code gates and mutation testing, balances rigor vs. developer friction, and prevents metric-gaming through review and tooling rather than ever-higher thresholds.
## Why enforce, not just measure Measuring coverage is useless if nobody acts on it. **Enforcement** means the build *fails* when coverage violates a rule, so a coverage regression can't be merged silently. This turns coverage from a vanity dashboard into a guardrail. ## The verification rule model A JaCoCo rule has three dimensions: - **Element / scope** — what unit the rule applies to: the whole **BUNDLE** (the module), each **PACKAGE**, each **CLASS**, or each **METHOD**. - **Counter** — which metric: **INSTRUCTION**, **BRANCH**, **LINE**, **METHOD**, **CLASS**, or **COMPLEXITY**. - **Limit / value** — a `minimum` (or `maximum`) on a **ratio** (e.g. 0.80 = 80%) or a **count** (e.g. `COVEREDRATIO`, `MISSEDCOUNT`). ## Gradle ```groovy jacocoTestCoverageVerification { violationRules { rule { limit { counter = 'BRANCH' value = 'COVEREDRATIO' minimum = 0.80 // fail if branch coverage < 80% } } rule { element = 'CLASS' limit { counter = 'LINE'; minimum = 0.50 } // no class below 50% lines excludes = ['**.generated.**', '**.*Dto'] } } } check.dependsOn jacocoTestCoverageVerification ``` Wiring `check.dependsOn` makes the standard `./gradlew check` (and thus CI) run the verification. ## Maven ```xml <execution> <id>check</id> <phase>verify</phase> <goals><goal>check</goal></goals> <configuration> <rules> <rule> <element>BUNDLE</element> <limits> <limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.80</minimum></limit> </limits> </rule> </rules> <excludes> <exclude>**/generated/**</exclude> <exclude>**/*Dto.class</exclude> </excludes> </configuration> </execution> ``` The `check` goal runs in the `verify` phase, so `mvn verify` (CI) fails on violations. ## Exclusions — what and how Not all code is meaningfully testable, and forcing tests on it wastes effort and dilutes the signal. Common **exclusions**: - **Generated code** — MapStruct mappers, protobuf/Avro classes, Lombok-generated members, ANTLR parsers. - **Plain data holders** — DTOs/records that are just fields and accessors. - **Bootstrapping** — a Spring Boot `main` / `@SpringBootApplication` class, config classes. Exclusions use **class-name glob patterns** (operating on the compiled class path, e.g. `**/dto/**`, `**/*Config.class`, `com/acme/generated/**`). Note JaCoCo matches **class names**, not source-file paths, and patterns differ slightly between the *report* (`excludes`) and the *agent* (`excludes`/`includes`). Over-excluding is a smell: it inflates the number by hiding real, untested logic. Exclude only code with **no branching logic worth testing**. ## The averaging trap and better gates A single **BUNDLE** ratio is an *average*. A new, fully untested class barely moves a large module's average, so genuinely risky code slips in under the bar. Mitigations: - Add a **per-CLASS** rule (e.g. "no class below 50%") so individual offenders fail. - Gate on **changed/new code** coverage (e.g. via the diff in CI or tools like Sonar's *new code* quality gate) rather than the whole-project average — this is the most effective in practice, because it stops *regressions* without demanding you retrofit legacy code. - Prefer **branch/instruction** counters over line for the reasons covered elsewhere. ## Don't game the metric A threshold creates an incentive. Bad responses: writing assertion-free tests that merely execute code, or excluding code until the number passes. Pair coverage gates with code review and, ideally, **mutation testing** (e.g. PIT), which checks that tests *fail* when the code is deliberately broken — the real test of assertion quality that coverage cannot measure.
- Why is gating on whole-project coverage average weaker than gating on new/changed code?The project average is dominated by existing code, so a new untested class barely moves it and passes the gate. A new-code (diff) gate enforces coverage on exactly the lines being added/changed, stopping regressions without forcing a retrofit of legacy code.
- A team hits its 80% gate by excluding packages until it passes. What's the real problem and a better safeguard?They're gaming the metric — excluding real logic inflates the number without improving safety. Better: limit exclusions to genuinely untestable/generated code, add per-class floors, and add mutation testing (e.g. PIT) so tests must actually catch injected faults, not just execute lines.
saying these in an interview costs you the question
- Excluding business logic to make the number pass — that defeats the gate.
- Relying on a single bundle-average threshold; it hides new untested classes.
- Assuming exclusion patterns match source paths — JaCoCo matches compiled class names.
- Treating passing the threshold as proof of quality without assertion checks (mutation testing).