|
| 1 | +package scoverage |
| 2 | + |
| 3 | +import munit.FunSuite |
| 4 | + |
| 5 | +import java.io.File |
| 6 | +import java.net.URLClassLoader |
| 7 | + |
| 8 | +/** Runtime regression test for #680. |
| 9 | + * |
| 10 | + * The reporter's minimal case (github.com/josephlbarnett/testscoverageissue) passes under |
| 11 | + * `mvn verify` and throws under `mvn scoverage:report`: |
| 12 | + * |
| 13 | + * java.lang.NullPointerException |
| 14 | + * at com.example.Wrapper.equals(Wrapper.scala:13) // obj.getClass != classOf[Wrapper] |
| 15 | + * at com.example.Wrapper.equals(Wrapper.scala:21) // other != null && ... |
| 16 | + * at com.example.WrapperTest.testEquals(WrapperTest.scala:11) |
| 17 | + * |
| 18 | + * The two stacked `equals` frames are the symptom: under instrumentation `other != null` is |
| 19 | + * compiled as `other.equals(null)` rather than a reference check, so it recurses into the |
| 20 | + * overridden `equals` with a genuinely null argument and dereferences it. |
| 21 | + * |
| 22 | + * PluginCoverageTest checks statement counts after compilation. This test loads the class |
| 23 | + * scoverage just instrumented and calls `equals` through reflection, so it fails with the |
| 24 | + * actual NullPointerException rather than with a changed count. |
| 25 | + */ |
| 26 | +class Issue680RegressionTest extends FunSuite { |
| 27 | + |
| 28 | + // Same shape as the reporter's Wrapper.scala; the names differ only to avoid clashing with |
| 29 | + // other snippets compiled into the shared test output directory. |
| 30 | + private val snippet = |
| 31 | + """ |
| 32 | + |package issue680 |
| 33 | + |class Bean680 { var id: String = _ } |
| 34 | + |class Wrapper680 { |
| 35 | + | var bean: Bean680 = _ |
| 36 | + | var code: String = _ |
| 37 | + | override def equals(obj: Any): Boolean = { |
| 38 | + | if (obj.getClass != classOf[Wrapper680]) { |
| 39 | + | return false |
| 40 | + | } else { |
| 41 | + | val other = obj.asInstanceOf[Wrapper680] |
| 42 | + | if ( |
| 43 | + | (other.code != code) || |
| 44 | + | (other.bean == null && bean != null) || |
| 45 | + | (other.bean != null && bean == null) || |
| 46 | + | (other != null && bean != null && other.bean.id != bean.id) |
| 47 | + | ) { |
| 48 | + | return false |
| 49 | + | } else { |
| 50 | + | return true |
| 51 | + | } |
| 52 | + | } |
| 53 | + | super.equals(obj) |
| 54 | + | } |
| 55 | + |} |
| 56 | + |""".stripMargin |
| 57 | + |
| 58 | + test( |
| 59 | + "instrumented Wrapper680.equals mirrors the #680 repro sequence without NullPointerException" |
| 60 | + ) { |
| 61 | + val compiler = ScoverageCompiler.default |
| 62 | + compiler.compileCodeSnippet(snippet) |
| 63 | + compiler.assertNoErrors() |
| 64 | + |
| 65 | + val outDir = new File(compiler.settings.outdir.value) |
| 66 | + val loader = |
| 67 | + new URLClassLoader(Array(outDir.toURI.toURL), getClass.getClassLoader) |
| 68 | + val wrapperClass = loader.loadClass("issue680.Wrapper680") |
| 69 | + val beanClass = loader.loadClass("issue680.Bean680") |
| 70 | + |
| 71 | + def newWrapper(): AnyRef = |
| 72 | + wrapperClass.getDeclaredConstructor().newInstance().asInstanceOf[AnyRef] |
| 73 | + def newBean(): AnyRef = |
| 74 | + beanClass.getDeclaredConstructor().newInstance().asInstanceOf[AnyRef] |
| 75 | + def setBean(w: AnyRef, b: AnyRef): Unit = |
| 76 | + wrapperClass.getMethod("bean_$eq", beanClass).invoke(w, b) |
| 77 | + def setBeanId(b: AnyRef, id: String): Unit = |
| 78 | + beanClass.getMethod("id_$eq", classOf[String]).invoke(b, id) |
| 79 | + def equalsCall(a: AnyRef, b: AnyRef): Boolean = |
| 80 | + wrapperClass |
| 81 | + .getMethod("equals", classOf[Object]) |
| 82 | + .invoke(a, b) |
| 83 | + .asInstanceOf[Boolean] |
| 84 | + |
| 85 | + // Step-by-step replay of WrapperTest.testEquals(). The *very first* call below is exactly |
| 86 | + // where the reporter's `mvn scoverage:report` run threw: both `bean` fields are still null |
| 87 | + // (Scala default), so evaluation of the `||` chain reaches the last clause and short-circuit |
| 88 | + // `&&` evaluates `other != null` first - the miscompiled comparison this test guards against. |
| 89 | + val a = newWrapper() |
| 90 | + val b = newWrapper() |
| 91 | + assert( |
| 92 | + equalsCall(a, b), |
| 93 | + "two fresh wrappers with null beans should be equal" |
| 94 | + ) |
| 95 | + |
| 96 | + val beanA = newBean() |
| 97 | + setBean(a, beanA) |
| 98 | + assert( |
| 99 | + !equalsCall(a, b), |
| 100 | + "wrapper with a bean should not equal one without a bean" |
| 101 | + ) |
| 102 | + |
| 103 | + val beanB = newBean() |
| 104 | + setBean(b, beanB) |
| 105 | + assert( |
| 106 | + equalsCall(a, b), |
| 107 | + "wrappers with two fresh (equal) beans should be equal again" |
| 108 | + ) |
| 109 | + |
| 110 | + setBeanId(beanA, "id") |
| 111 | + assert(!equalsCall(a, b), "differing bean ids should not be equal") |
| 112 | + |
| 113 | + setBeanId(beanB, "id") |
| 114 | + assert(equalsCall(a, b), "matching bean ids should be equal again") |
| 115 | + } |
| 116 | +} |
0 commit comments