This project demonstrates a JaCoCo false-negative coverage measurement bug that affects
Kotlin interfaces containing a companion object with a @JvmStatic-annotated method.
All tests pass and fully exercise the method under test, yet JaCoCo reports it as 0% covered with 1 missed instruction.
- JDK 17+
- Gradle 8.x on
PATH(only needed once to bootstrap the wrapper)
cd jacoco-jvmstatic-interface-bug
gradle wrapper --gradle-version 8.12This generates gradlew, gradlew.bat, and gradle/wrapper/gradle-wrapper.jar.
./gradlew test jacocoTestReportIConfigTest > forUser returns EnterpriseConfig when isConsumer is false PASSED
IConfigTest > forUser returns ConsumerConfig when isConsumer is true PASSED
IConfigTest > EnterpriseConfig returns expected base URL PASSED
IConfigTest > ConsumerConfig strips query parameters from URL PASSED
BUILD SUCCESSFUL
Open the HTML report:
build/reports/jacoco/test/html/index.html
Navigate to com.example.coverage → click IConfig.
You will see:
| Element | Missed Instr. | Cov. Instr. | Missed Lines | Coverage |
|---|---|---|---|---|
forUser(boolean) |
1 | 0 | 1 | 0% |
In the source view, the closing } of forUser is highlighted red (not covered).
The function declaration and return statement have no highlight at all — they are
not tracked in this class entry (see Root Cause below for why).
In build/reports/jacoco/test/jacocoTestReport.xml, find <class name="com/example/coverage/IConfig">:
<method name="forUser" desc="(Z)Lcom/example/coverage/IConfig;" line="43">
<counter type="INSTRUCTION" missed="1" covered="0"/>
<counter type="BRANCH" missed="0" covered="0"/>
<counter type="LINE" missed="1" covered="0"/>
<counter type="COMPLEXITY" missed="1" covered="0"/>
<counter type="METHOD" missed="1" covered="0"/>
</method>And in <sourcefile name="IConfig.kt">:
<!-- Only the closing brace line appears; the declaration and return lines have no entry -->
<line nr="47" mi="1" ci="0" mb="0" cb="0"/>(nr is the line number of the closing } of forUser in IConfig.kt.)
When @JvmStatic is placed on a method inside a companion object of a Kotlin
interface, kotlinc emits two separate class files:
| Class file | Contents |
|---|---|
IConfig$Companion.class |
The actual method body of forUser |
IConfig.class |
A synthetic static bridge that delegates to the companion |
The bridge in IConfig.class is roughly equivalent to:
// Synthetic — generated by kotlinc for Java interop
public static IConfig forUser(boolean isConsumer) {
return IConfig.Companion.forUser(isConsumer);
}Its bytecode LineNumberTable maps every instruction to the closing brace line of
the forUser method body in the Kotlin source — that closing } is the only line
number entry the bridge carries.
When a Kotlin call site writes IConfig.forUser(...), the Kotlin compiler resolves
the call directly to IConfig$Companion.forUser(...). The static bridge is never
invoked. Only a Java call site (e.g., IConfig.forUser(true) in a .java file)
goes through it.
All tests in this project are Kotlin, so the bridge instruction is never executed.
| Class entry | forUser status |
|---|---|
IConfig$Companion |
Fully covered — the real method body ran |
IConfig |
0% covered — the bridge was never reached |
JaCoCo maps the bridge's single missed instruction to the closing } line, producing
the false "1 missed line" report under IConfig.kt.
Lines corresponding to the function declaration and return statement appear in the
IConfig$Companion class entry (as covered), not in the IConfig entry, which is why
they show no coverage highlight at all in the IConfig.kt source view.
Search the XML for IConfig$Companion:
<class name="com/example/coverage/IConfig$Companion" ...>
<method name="forUser" desc="(Z)Lcom/example/coverage/IConfig;">
<counter type="INSTRUCTION" missed="0" covered="5"/>
<counter type="METHOD" missed="0" covered="1"/>
</method>
</class>The companion class entry shows full coverage. The bug is that JaCoCo does not suppress
the uninvocable synthetic bridge from the coverage metrics of IConfig.
| Component | Version |
|---|---|
| Kotlin | 2.1.20 |
| JaCoCo | 0.8.14 |
| Gradle | 8.12 |
| JVM | OpenJDK 17 |
| Issue | Subject | Difference |
|---|---|---|
| jacoco#978 | Kotlin interface $DefaultImpls |
Concerns default method bodies, not @JvmStatic factory methods |
| jacoco#1905 | Kotlin 2.2.0 binary-compat bridges | Bridges for ABI stability across Kotlin versions, not Java-interop bridges |
The unique combination here is:
@JvmStaticannotation- Inside a
companion object - Inside an interface (not a class)
- Called only from Kotlin (so the Java-interop bridge is never reached)
-
Add a Java test that calls
IConfig.forUser(...)from a.javafile. This triggers the static bridge and removes the false negative — but requires adding an otherwise-unnecessary Java caller. -
Exclude
IConfig.classvia a JaCoCo class-directory filter (e.g.,**/IConfig.class). This over-excludes if the interface has other coverable code such as default method implementations. -
Remove
@JvmStaticif Java interop is not required. The companion method remains callable from Kotlin asIConfig.forUser(...)regardless; Kotlin resolves that to the companion without needing the bridge. -
Wait for a JaCoCo fix that either:
- Suppresses synthetic interop bridges on interface companion classes from line/instruction/method coverage (preferred), or
- Merges bridge coverage with the companion method so that covering
IConfig$Companion.forUsercounts the bridge as covered.
src/
├── main/kotlin/com/example/coverage/
│ ├── IConfig.kt # Interface with @JvmStatic companion — triggers the bug
│ ├── EnterpriseConfig.kt # Enterprise implementation
│ ├── ConsumerConfig.kt # Consumer implementation
│ └── ConfigProvider.kt # Two-branch factory that selects the implementation
└── test/kotlin/com/example/coverage/
└── IConfigTest.kt # 4 tests, all Kotlin callers, all pass
See ISSUE.md for the upstream JaCoCo bug report text.