Setting Up Kover in a Multi-Module KMP Project
We recently added code coverage to our Kotlin Multiplatform project using Kover. The project has ~9 modules spanning a Ktor backend, shared KMP libraries, and Compose Multiplatform clients. Here's how we set it up, and where we had to get creative.
The Basics
Kover plugin is applied at the root to aggregate coverage across modules:
// root build.gradle.kts
plugins {
alias(libs.plugins.kover)
}
val coveredModulePaths = listOf(
":backend", ":shared:api", ":shared:common",
":shared:database", ":shared:storage",
":application:shared", ":application:desktopApp"
// ... other modules
)
dependencies {
coveredModulePaths.forEach { kover(project(it)) }
}
We exclude generated code that would inflate numbers without representing real logic:
kover {
reports {
filters {
excludes {
packages("com.example.client.db") // SQLDelight generated
classes("*ComposableSingletons*") // Compose generated singletons
classes("*\$\$serializer") // kotlinx.serialization generated
}
}
total {
html { onCheck = false }
xml { onCheck = false }
}
}
}
Each module then applies Kover via a convention plugin — id("our-kover") — rather than configuring it inline.
Auto-Ratcheting Coverage Floors
We didn't want a single project-wide coverage threshold. Instead, each module has its own baseline stored in a coverage-baselines.json file:
{
"_comment": "Auto-managed coverage floor. Do not lower values manually.",
"_updated": "2026-03-21",
"modules": {
":backend": { "line": 64.9 },
":shared:storage": { "line": 91.2 },
":application:shared": { "line": 21.9 }
}
}
The convention plugin reads this at configuration time and sets a minBound verification rule:
class KoverPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
apply(plugin = plugin("kover").pluginId)
val baselineFile = rootProject.file("coverage-baselines.json")
if (baselineFile.exists()) {
val json = JsonSlurper().parseText(baselineFile.readText()) as Map<String, Any?>
val modules = json["modules"] as? Map<String, Any?> ?: emptyMap()
val moduleEntry = modules[path] as? Map<String, Any?>
// Baselines are fractional (64.9); minBound takes Int, so floor it
val minLine = (moduleEntry?.get("line") as? Number)?.toDouble()?.toInt() ?: 0
if (minLine > 0) {
configure<KoverProjectExtension> {
reports {
verify {
rule("$path coverage floor") {
minBound(minLine)
}
}
}
}
}
}
}
}
Baselines only go up, never down. A koverRatchet task runs on merge to the main branch, parses each module's XML report, and bumps the baseline if coverage improved:
tasks.register("koverRatchet") {
coveredModulePaths.forEach { dependsOn("$it:koverXmlReport") }
doLast {
reportFiles.forEach { (mod, reportFile) ->
// Parse LINE counter from XML, compute percentage
val pct = /* ... */ Math.floor(covered * 1000.0 / total) / 10.0
if (pct > currentBaseline) {
// Update JSON — only ratchets upward
}
}
}
}
We store baselines to one decimal place. In a large codebase, a single new test file might only move coverage by 0.2% — without fractional precision, that improvement would be invisible.
Handling Parallel Test Forks
Our backend tests run with maxParallelForks (4 forks in CI), each connecting to its own database shard (test_db, test_db_2, test_db_3, test_db_4). This is where we expected Kover to get complicated — it didn't.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
val forkCount = (findProperty("testForks") as? String)?.toIntOrNull() ?: 1
maxParallelForks = forkCount
}
Kover aggregates coverage across all forks automatically at the module level. Each JVM fork contributes its execution data, and koverXmlReport merges them into a single report. No extra configuration needed.
The one wrinkle: our CI bash script that enforces baselines skips the backend module. Because forks can produce slightly different per-run aggregation, we rely on koverVerify (which uses the floored integer baseline) for backend enforcement rather than the fractional bash-level check. The convention plugin handles this — minBound(64) from a 64.9 baseline gives enough headroom for fork variance while still catching real regressions.
CI: Per-Module Reports as PR Comments
Each CI job runs its module's tests alongside koverVerify and koverXmlReport:
- name: Backend Tests
run: |
./gradlew :backend:test -PtestForks=4 \
:backend:koverVerify \
:backend:koverXmlReport
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage-backend
path: backend/build/reports/kover/report.xml
A downstream job downloads all artifacts and runs a bash script that:
- Finds each module's
report.xmlby matching artifact directory names - Parses coverage from the XML
- Builds a markdown table with Unicode coverage bars
- Posts it as a PR comment
- Fails the job if any non-backend module dropped below baseline
The coverage bar is a simple 10-segment visual:
coverage_bar() {
local filled=$(( ${1%.*} / 10 ))
local empty=$(( 10 - filled ))
local bar="\`"
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
echo "$bar\`"
}
Result looks like:
| Module | Coverage | Baseline | Delta |
|---|---|---|---|
| Backend | ██████░░░░ 64.9% |
64.9% | — |
| Storage | █████████░ 91.2% |
91.2% | — |
The "Last LINE Counter" Gotcha
This one cost us a few CI iterations. Kover XML reports contain multiple <counter type="LINE"> elements — one at the end of each package/class section and one at the report root level (the total). The total is the last one in the file.
Our first attempt used grep -m1 which grabbed the first (smallest) counter — a single class's coverage, not the module total. The fix was to use awk that overwrites on every match, so only the last value survives:
parse_coverage() {
awk '/type="LINE"/ {
s = $0
sub(/.*covered="/, "", s); sub(/".*/, "", s); c = s
s = $0
sub(/.*missed="/, "", s); sub(/".*/, "", s); m = s
} END { if (c != "" && m != "") print c, m }' "$1"
}
Same approach in the Gradle koverRatchet task — iterate all <counter> nodes and keep overwriting, so the last type="LINE" wins.
Summary
- Convention plugin per module, reading floors from a shared JSON file
- Auto-ratcheting on merge — baselines only go up, stored to one decimal
- Parallel forks just work with Kover — it aggregates across JVM forks automatically
- Backend gets special treatment in CI enforcement due to fork variance — uses
koverVerifyinstead of bash-level fractional checks - Parse the last LINE counter, not the first — the module total is at the end of the XML
- Exclude generated code (SQLDelight, Compose singletons, serializers) to keep numbers honest