iru-kmp-coverage
Generate a Kotlin Multiplatform (Android + iOS) project's unit-test line/branch coverage with the Kover Gradle plugin (`org.jetbrains.kotlinx.kover`) — the root-aggregated `./gradlew koverXmlReport` (
Файлы1
Открыть в GitHubSKILL.md.claude/skills/iru-kmp-coverage/SKILL.md
GitHubKMP Coverage
Run the Kover coverage a KMP project has wired and report exact, measured line/branch coverage for the scope the user cares about — per class, per module, and for the whole project. This skill only measures and reports; it never adds tests or modifies source, test, or build files.
Step 0 — Resolve inputs
[scope]— comma-separated class simple names (Greeter), source paths (shared/src/commonMain/…/Greeter.kt), or a module (:shared,androidApp). No scope → the whole project.key: valuelines (explicit keys win):tests: <pattern>(a Gradle--testspattern to narrow the test run — coverage then only reflects those tests; see Step 3),bar: <percent>(default 80, line coverage — the catalog-wide gate),module: <gradle path>,ios-app: yes|no(defaultno, Step 5),lock: held(the caller already holds.integration-test.lock).
Prerequisites are iru-kmp-test's Step 1 (JDK 17/21 on JAVA_HOME, Android SDK via ANDROID_HOME or
local.properties, ./gradlew present) — check them first; a missing one fails at configuration time and looks
like a coverage problem.
Step 1 — Detect whether Kover is wired, and where
grep -nE 'org\.jetbrains\.kotlinx\.kover|libs\.plugins\.kover|kotlinx-kover|id\("kover"\)' build.gradle.kts */build.gradle.kts gradle/libs.versions.toml 2>/dev/null
grep -nE 'kover\(project\(' build.gradle.kts 2>/dev/null
- Root aggregation — the root build applies Kover and declares
dependencies { kover(project(":shared")); kover(project(":androidApp")) }(this catalog'siru-setup-kmp-applayout). Use the rootkoverXmlReport: one report for every aggregated module. Note which modules are not listed — their code isn't in the total. - Per-module only — Kover applied in module build files but not aggregated at the root. Run each module's report task (Step 2) and report them separately; never sum percentages across reports.
- Not wired — no Kover anywhere. Look for a JaCoCo alternative before giving up: the Android app module's
enableUnitTestCoverage = true(AGP'screateDebugUnitTestCoverageReport) — if present, measure that module the wayiru-android-coverage's Step 2 does and say the KMP module itself is unmeasured. Otherwise stop and report "coverage not wired": noorg.jetbrains.kotlinx.koverplugin in any build file (and no AGP unit-test coverage) — never a percentage, never "0%", never a pass.
Confirm task names before running (they depend on the targets and the Kover version):
./gradlew tasks --all | grep -E '^([a-zA-Z]+:)?koverXmlReport'
Step 2 — Run the report task
| Setup | Command | XML |
|---|---|---|
| Root aggregation | ./gradlew koverXmlReport |
build/reports/kover/report.xml |
| KMP module, all JVM targets | ./gradlew :shared:koverXmlReport |
shared/build/reports/kover/report.xml |
| KMP module, Android target only | ./gradlew :shared:koverXmlReportAndroid |
shared/build/reports/kover/reportAndroid.xml |
| Android app module, debug variant | ./gradlew :androidApp:koverXmlReportDebug |
androidApp/build/reports/kover/reportDebug.xml |
Kover's report tasks depend on the test tasks they measure (verified: the root koverXmlReport ran
:shared:testAndroidHostTest and :androidApp:testDebugUnitTest itself), so no separate test run is needed. They
never run Kotlin/Native tests — iOS code isn't instrumentable — so this skill needs no simulator and no lock unless
Step 5 runs.
If the underlying test run fails (compile error or failing test), report that failure and stop — don't parse a
report left over from an earlier run. Retry once with --no-configuration-cache on a configuration-cache error
unrelated to coverage.
Step 3 — Narrowing the test run (tests:)
./gradlew :shared:testAndroidHostTest --tests '<pattern>' :shared:koverXmlReport
The --tests filter binds to the test task named right before it (iru-kmp-test's Step 3), so name the test task
explicitly with the filter, then the report task. Verified: filtering to *.AndroidPlatformTest produced a report in
which Greeter (only exercised by GreeterTest) dropped to 0% — coverage only reflects the tests that ran. A
caller gating on a class's coverage must include every test class that exercises it, or omit tests: and run the
whole suite; when in doubt, prefer the broader run (the same caution as iru-java-coverage's Step 1).
Step 4 — Parse the Kover XML
Kover writes JaCoCo's XML schema (<report> → <package> → <class name="…" sourcefilename="…"> with <counter type="LINE|BRANCH|INSTRUCTION|METHOD" missed covered/>, plus <sourcefile> → <line nr mi ci mb cb/>). The parser
below maps every class back to <module>:<sourceSet> by locating its source file, prints totals, per-class and
per-module figures with uncovered lines for classes under the bar, and lists the Kotlin/Native source sets as not
measurable. Run it from the repository root:
python3 - <report.xml> <bar> [scope ...] <<'PY'
import glob, sys, xml.etree.ElementTree as ET
from collections import defaultdict
# argv: <report.xml> <threshold %> [scope ...] (scope = class simple name or path fragment)
report, bar, scopes = sys.argv[1], float(sys.argv[2]), sys.argv[3:]
def counters(elem):
return {c.get("type"): (int(c.get("missed")), int(c.get("covered"))) for c in elem.findall("counter")}
def pct(c, kind):
m, v = c.get(kind, (0, 0))
return None if m + v == 0 else 100.0 * v / (m + v)
def fmt(c, kind):
p = pct(c, kind)
m, v = c.get(kind, (0, 0))
return "n/a" if p is None else f"{p:.1f}% ({v}/{m + v})"
def locate(pkg, filename):
# map a report entry back to <module>/src/<sourceSet>/... so the summary can group by module and source set
hits = glob.glob(f"**/src/*/kotlin/{pkg}/{filename}", recursive=True) + \
glob.glob(f"**/src/*/java/{pkg}/{filename}", recursive=True)
hits = [h for h in hits if "/build/" not in h]
if not hits:
return "?", "?"
parts = hits[0].split("/")
i = parts.index("src")
return "/".join(parts[:i]) or ".", parts[i + 1]
root = ET.parse(report).getroot()
tot = counters(root)
print(f"TOTAL line={fmt(tot, 'LINE')} branch={fmt(tot, 'BRANCH')} bar={bar:.0f}% "
f"{'MEETS' if (pct(tot, 'LINE') or 0) >= bar else 'BELOW'}")
per_module = defaultdict(lambda: [0, 0, 0, 0])
for pkg in root.findall("package"):
pname = pkg.get("name")
uncovered = {sf.get("name"): [l.get("nr") for l in sf.findall("line") if l.get("ci") == "0"]
for sf in pkg.findall("sourcefile")}
for cls in pkg.findall("class"):
fqcn = cls.get("name").replace("/", ".")
src = cls.get("sourcefilename", "")
module, source_set = locate(pname, src)
c = counters(cls)
lm, lc = c.get("LINE", (0, 0)); bm, bc = c.get("BRANCH", (0, 0))
acc = per_module[module]; acc[0] += lm; acc[1] += lc; acc[2] += bm; acc[3] += bc
if scopes and not any(s in fqcn or s in f"{module}/src/{source_set}/{pname}/{src}" for s in scopes):
continue
p = pct(c, "LINE")
flag = "" if p is None or p >= bar else " BELOW"
print(f"CLASS {fqcn} [{module}:{source_set}] line={fmt(c, 'LINE')} branch={fmt(c, 'BRANCH')}{flag}")
if flag and uncovered.get(src):
print(f" UNCOVERED {pname}/{src}: {','.join(uncovered[src])}")
for module, (lm, lc, bm, bc) in sorted(per_module.items()):
c = {"LINE": (lm, lc), "BRANCH": (bm, bc)}
print(f"MODULE {module} line={fmt(c, 'LINE')} branch={fmt(c, 'BRANCH')}")
# Kotlin/Native source sets never reach a JVM coverage report - list them as not measurable
native = sorted({f.split("/src/")[0] + ":" + f.split("/src/")[1].split("/")[0]
for f in glob.glob("**/src/ios*Main/kotlin/**/*.kt", recursive=True)
+ glob.glob("**/src/apple*Main/kotlin/**/*.kt", recursive=True)
+ glob.glob("**/src/native*Main/kotlin/**/*.kt", recursive=True) if "/build/" not in f})
for n in native:
print(f"NOT MEASURABLE {n} (Kotlin/Native - no coverage tool)")
PY
Notes on reading it:
- A
[scope]class that doesn't appear at all was never loaded by the JVM runs (Kover, unlike JaCoCo, usually lists unloaded classes at 0%, but a file containing onlyexpectdeclarations or only iOS code has no JVM class) — check whether its source file lives in an iOS source set (→ not measurable) before reporting 0%. expect/actual: theexpectdeclaration has no bytecode; its Androidactual(Platform_androidKtfor a top-levelactual funinPlatform.android.kt) is measured underandroidMain, its iOSactualis not measurable. Kotlin file-level classes appear as<File>Kt; companion objects, lambdas and coroutine state machines as$-suffixed classes — call compiler-generated gaps out as noise, asiru-java-coveragedoes.- The
BELOWflag and the gate use line coverage againstbar; branch coverage is reported for information. Kotlin/Native code is excluded from the denominator by construction and never counts against the bar — report it separately so nobody reads the total as "the whole app".
Step 5 — Optional: iosApp Swift coverage (ios-app: yes, information only)
The SwiftUI host app's own Swift code (iosApp/iosApp/) can be measured by Xcode, never by Kover. Only on request,
only on macOS with Xcode, and only behind .integration-test.lock (acquire/release exactly as iru-kmp-test's
Step 4 — it boots a simulator):
rm -rf "$TMPDIR/iru-kmp-coverage-ios.xcresult"
xcodebuild test -project iosApp/iosApp.xcodeproj -scheme iosApp \
-destination 'platform=iOS Simulator,name=<device>' -enableCodeCoverage YES \
-resultBundlePath "$TMPDIR/iru-kmp-coverage-ios.xcresult"
xcrun xccov view --report --json "$TMPDIR/iru-kmp-coverage-ios.xcresult"
Read it the way iru-swift-coverage's "App: xccov" section does (per-target/per-file lineCoverage; no branch
coverage exists for Swift), report only the iosApp target's files, label the figure informational — not part of
the gate, and ignore the embedded Shared framework's entry (Kotlin/Native code compiled into a framework carries
no Swift coverage mapping). When the project isn't generated or the simulator is unavailable, say this part was
skipped and why.
Step 6 — Report
- The engine and setup used (Kover version if visible, root aggregation or per-module), the report path(s), and the
test pattern if
tests:narrowed the run. - The total line/branch %, per-module line/branch %, and per in-scope class line/branch % — all from the XML
counters, never estimated — and whether each meets
bar. For each class under the bar, its uncovered line numbers so the caller (oriru-kmp-generate-all-tests) knows where to add tests. - Not measurable: every Kotlin/Native source set with code (e.g.
shared:iosMain), stated as excluded from the gate, never as 0% and never as failing. - Coverage not wired, when Step 1 found no Kover — naming what's missing — instead of any percentage.
- Step 5's informational iosApp figure, or that it was skipped.
Do not add tests, touch build files, or re-run anything to raise the number — that's a follow-up for the user,
iru-kmp-code-one-task-group, or iru-kmp-generate-all-tests.
Known quirks / verification notes
Verified in $TMPDIR/iru-verify/kmp/iru-kmp-coverage/ (October 2026, Gradle 9.8.1, JDK 21, Kotlin 2.4.21, AGP 9.4.1
with com.android.kotlin.multiplatform.library, Kover 0.9.11 — the current release; look it up at
https://plugins.gradle.org/m2/org/jetbrains/kotlinx/kover/org.jetbrains.kotlinx.kover.gradle.plugin/maven-metadata.xml):
- Kover applied to
shared+androidAppand aggregated at the root:./gradlew koverXmlReportran:shared:testAndroidHostTestand:androidApp:testDebugUnitTestand wrotebuild/reports/kover/report.xml(<report name="Kover Gradle Plugin XML report for :">) with both modules' packages;:shared:koverXmlReport,:shared:koverXmlReportAndroidand:androidApp:koverXmlReportDebugwroteshared/build/reports/kover/report.xml,…/reportAndroid.xmlandandroidApp/build/reports/kover/reportDebug.xml. No iOS task ran. - The Step 4 parser on the aggregated report printed
TOTAL line=62.5% (5/8) branch=50.0% (2/4) bar=80% BELOW,CLASS com.example.shared.Greeter [shared:commonMain] line=100.0% (3/3) branch=50.0% (1/2),CLASS com.example.shared.Platform_androidKt [shared:androidMain] …,CLASS com.example.app.MainActivity [androidApp:main] line=0.0% (0/3) … BELOWwithUNCOVERED com/example/app/MainActivity.kt: 7,9,10, per-module lines, andNOT MEASURABLE shared:iosMain; scoping toGreeterprinted only that class. ./gradlew :shared:testAndroidHostTest --tests '*.AndroidPlatformTest' :shared:koverXmlReporthonoured the filter (Greeter at 0%).- Unverified locally: Step 5's
xcodebuild -enableCodeCoverage/xccovpath for aniosApp(no XcodeGen here to generate one — the commands and JSON shape areiru-swift-coverage's, verified there), the AGPcreateDebugUnitTestCoverageReportfallback (seeiru-android-coverage), and Kover with anandroidTarget()+com.android.librarymodule (its Android report task is then variant-named, e.g.koverXmlReportDebug— confirm with the Step 1 task listing).