Setting Up Kover in a Multi-Module KMP Project

Setting Up Kover in a Multi-Module KMP Project
Photo by Marc Reichelt / Unsplash

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:

  1. Finds each module's report.xml by matching artifact directory names
  2. Parses coverage from the XML
  3. Builds a markdown table with Unicode coverage bars
  4. Posts it as a PR comment
  5. 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 koverVerify instead 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