Skip to content

psancheti6666/jacoco-jvmstatic-interface-bug

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JaCoCo Bug Reproducer: @JvmStatic in Kotlin Interface Companion Object

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.


Quick Start

Prerequisites

  • JDK 17+
  • Gradle 8.x on PATH (only needed once to bootstrap the wrapper)

Step 1 — Bootstrap the Gradle wrapper (first time only)

cd jacoco-jvmstatic-interface-bug
gradle wrapper --gradle-version 8.12

This generates gradlew, gradlew.bat, and gradle/wrapper/gradle-wrapper.jar.

Step 2 — Run tests and generate the coverage report

./gradlew test jacocoTestReport

Expected Outcome

Tests: all green

IConfigTest > 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

Coverage report: IConfig.forUser incorrectly shown as 0% covered

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).

XML evidence

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.)


Root Cause

Two class files are generated

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.

Kotlin callers bypass the bridge

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.

What JaCoCo sees

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.

Confirming the companion is covered

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.


Affected Versions

Component Version
Kotlin 2.1.20
JaCoCo 0.8.14
Gradle 8.12
JVM OpenJDK 17

Distinguishing This From Known Issues

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:

  • @JvmStatic annotation
  • Inside a companion object
  • Inside an interface (not a class)
  • Called only from Kotlin (so the Java-interop bridge is never reached)

Workarounds

  1. Add a Java test that calls IConfig.forUser(...) from a .java file. This triggers the static bridge and removes the false negative — but requires adding an otherwise-unnecessary Java caller.

  2. Exclude IConfig.class via a JaCoCo class-directory filter (e.g., **/IConfig.class). This over-excludes if the interface has other coverable code such as default method implementations.

  3. Remove @JvmStatic if Java interop is not required. The companion method remains callable from Kotlin as IConfig.forUser(...) regardless; Kotlin resolves that to the companion without needing the bridge.

  4. 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.forUser counts the bridge as covered.

Project Structure

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.

About

JaCoCo false-negative coverage bug reproducer: @JvmStatic in Kotlin interface companion object reports closing brace as uncovered despite tests passing

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors