
After a GT race, every car rolls onto the weighbridge before parc fermé. It has to clear a minimum weight, set before the season and revised only upward as the series collects more data on what’s fair. A car that comes in light gets disqualified, no matter how well it drove. Teams don’t get to argue the number down when it’s inconvenient. They add ballast, or they find grip somewhere else.
I think about that weighbridge every time I look at a coverage report.
A percentage on its own tells you almost nothing. Ninety percent coverage on a module full of generated code and empty getters is worse than sixty percent on a module where every line was actually exercised by a test that would fail if you broke something. I found this out the annoying way, wiring Kover into a project with close to twenty modules: the number moved, but it wasn’t telling me the truth.
The number was lying before it was useful
The first pass was simple. Apply the Kover plugin, point it at every module, generate a report. The merged number came out somewhere in the low sixties. Respectable enough on paper, wrong in a way that mattered.
Domain modules with almost no real logic were sitting near the top of the report, and it wasn’t because they were well tested. It was because a dependency injection library had generated a pile of factory classes and graph interfaces that every counted line inflated the denominator on, without a single one of them ever being able to fail a test. Compose @Preview functions did the same thing in the other direction: pure decoration, zero tests, dragging modules with real logic down next to modules that had none.
You can’t set a meaningful floor on a number built like that. So before the floor came the exclude list, and before the exclude list came trusting the report at all.
Coverage across more than one module
A single-module Android app can get away with Kover’s defaults: apply the plugin, run koverHtmlReport, done. A multi-module KMP project can’t, for two reasons.
First, most modules don’t need coverage measured at all. Pure API modules, thin platform-wrapper modules, anything with no meaningful branches, don’t need a plugin dragging a bytecode instrumentation step into every build. Second, the number you actually care about is the merged one across every module that does carry logic, not twenty separate reports you have to average in your head.
The way I did was creating a convention plugin that applies Kover to a module and defines the excludes, plus a root-level aggregation that pulls the modules worth measuring into one merged report.
// build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt
class KoverConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
val libs = the<LibrariesForLibs>()
pluginManager.apply(libs.plugins.kover.get().pluginId)
extensions.configure<KoverProjectExtension> {
reports {
filters {
excludes {
classes(
// DI-generated factories and contribution classes.
"*\$Factory*",
"*\$ContributionToAppScope*",
// Compose-compiler-generated lambda holders, no logic of their own.
"*ComposableSingletons*",
"*Previews",
"*PreviewsKt",
// Generated resource accessors.
"*.generated.resources.*",
// Pure DI wiring: every class under a `.di.` package here
// is graph construction, nothing to unit test.
"*.di.*",
)
}
}
}
}
}
}
}
Any module with tests worth counting applies this plugin instead of wiring Kover from scratch:
// core/network/build.gradle.kts
plugins {
id("template.kover")
// ...other convention plugins
}
The root project then declares which modules feed the merged report, and repeats the same excludes so the merged view and each module’s own view agree with each other:
// build.gradle.kts (root)
plugins {
alias(libs.plugins.kover)
}
dependencies {
// Only modules with real tests are listed here. A module with nothing
// to measure doesn't need to show up in the merged number at all.
kover(project(":core:common"))
kover(project(":core:network"))
kover(project(":core:database"))
kover(project(":feature:auth:domain"))
kover(project(":feature:auth:data"))
kover(project(":feature:profile:presentation"))
}
kover {
reports {
filters {
excludes {
// Keep in sync with KoverConventionPlugin's excludes.
classes(
"*\$Factory*",
"*\$ContributionToAppScope*",
"*ComposableSingletons*",
"*Previews",
"*PreviewsKt",
"*.generated.resources.*",
"*.di.*",
)
}
}
}
}
That “keep in sync” comment is doing real work. Kover reads excludes from wherever the report is generated. Exclude a class in the convention plugin but forget the same line in the root aggregation, and the merged report counts it again, quietly, and your floor starts failing for a reason that has nothing to do with a real regression.
Excluding the honest way
The instinct when a module reads 0% is to exclude your way to a better number. That’s the wrong direction to pull. The right question for anything you’re about to exclude is: could a test on this class ever fail for a real reason? If yes, it stays in the count, even if writing that test is annoying. If no, excluding it isn’t gaming the number, it’s correcting a measurement error.
Generated database entities and query classes are the clearest case. A SQLDelight-generated UserEntityQueries class has no branches a test could meaningfully assert on beyond “does the generated SQL run,” which your actual repository tests already cover indirectly.
excludes {
classes(
"com.example.app.core.database.UserEntity",
"com.example.app.core.database.UserEntityQueries",
"com.example.app.core.database.UserEntityQueries\$*",
)
}
The exclude list will grow every time you add a code generator. A new annotation processor, a new resource pipeline, a new DI library migration, each one leaves generated classes in its wake that need the same treatment. Treat additions to this list the same way you’d treat additions to .gitignore: expected maintenance, not a sign something’s wrong.
The floor that only goes up
Once the report was honest, setting a floor was the easy part.
kover {
reports {
verify {
rule {
// Baseline was ~64% after excluding generated code.
// Raised to 68% once core/network got real coverage.
// Raised to 75% after the auth data layer got tests
// for its *RepositoryImpl classes.
// Never lower this to make a failing build pass.
minBound(75)
}
}
}
}
koverVerify fails the build if merged line coverage drops below that number. The number itself is a ratchet, not a target. Every time real tests push the baseline up, the floor moves up behind it, a couple of points below the current baseline so unrelated refactors don’t fail the build on noise. It never moves down. If a change makes coverage drop, the fix is to write the missing tests, not to edit the floor.
That’s the whole point of the weighbridge analogy holding up under pressure. A minimum weight limit that teams could talk their way under whenever it was inconvenient wouldn’t be a rule, it would be a suggestion. Same with a coverage floor you lower the first time it’s in your way.
Wiring it into CI
Locally, koverVerify runs alongside koverHtmlReport and koverXmlReport as part of the same pre-merge check that runs Detekt (covered in The White Lines). Coverage belongs in the same slot: a gate that runs before a PR is allowed to merge, not a report someone checks manually once a month.
If your KMP project already has a GitHub Actions workflow running unit tests on every pull request, adding a coverage gate is one more step, not a new pipeline:
# .github/workflows/tests.yml
name: Unit Tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: Run unit tests
run: ./gradlew testDebugUnitTest --max-workers=2
- name: Enforce coverage floor
run: ./gradlew koverXmlReport koverVerify
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: kover-report
path: build/reports/kover/
koverVerify fails the job the same way a failing test does, so there’s no extra branching logic to write. The XML report is worth generating even if nothing downstream reads it yet: it’s the format most coverage-badge and PR-annotation tools expect, and generating it costs nothing extra once koverXmlReport is already in the command.
One gotcha if you’re running this on Android modules with Robolectric tests: those tests are memory-hungry, and Kover’s instrumentation adds overhead on top. If the job runs out of memory before it gets anywhere near reporting a real coverage number, raise the runner’s heap before you start suspecting the plugin:
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g"
The line I hold
A coverage percentage nobody enforces is decoration. It sits at the top of a README, it looks fine in a badge, and it means nothing because there’s no consequence when it drops. The moment koverVerify runs on every pull request and fails the build, the number stops being decoration and starts being a rule the team actually has to answer to.
I’d rather have a floor at seventy-five that only goes up than a badge that says ninety and hasn’t been checked since the module was scaffolded.
Everyone crosses the weighbridge. Nobody gets to argue the number down. 🏁
Comments
Loading comments...