Skip to content

Commit 61bb2c4

Browse files
committed
Emit Scala.js JUnit bootstrappers for JUnit test classes.
This allows Scala.js for dotty to support normal JUnit tests.
1 parent d51356b commit 61bb2c4

File tree

7 files changed

+438
-1
lines changed

7 files changed

+438
-1
lines changed

.drone.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pipeline:
3131
image: lampepfl/dotty:2019-04-22
3232
commands:
3333
- cp -R . /tmp/2/ && cd /tmp/2/
34-
- ./project/scripts/sbt ";dotty-bootstrapped/compile ;dotty-bootstrapped/test; dotty-semanticdb/compile; dotty-semanticdb/test:compile;sjsSandbox/run"
34+
- ./project/scripts/sbt ";dotty-bootstrapped/compile ;dotty-bootstrapped/test; dotty-semanticdb/compile; dotty-semanticdb/test:compile;sjsSandbox/run;sjsSandbox/test"
3535
- ./project/scripts/bootstrapCmdTests
3636

3737
community_build:

compiler/src/dotty/tools/backend/sjs/JSDefinitions.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,40 @@ final class JSDefinitions()(implicit ctx: Context) {
189189
def isJSThisFunctionClass(cls: Symbol): Boolean =
190190
isScalaJSVarArityClass(cls, "ThisFunction")
191191

192+
/** Definitions related to the treatment of JUnit boostrappers. */
193+
object junit {
194+
lazy val TestAnnotType: TypeRef = ctx.requiredClassRef("org.junit.Test")
195+
def TestAnnotClass(implicit ctx: Context): ClassSymbol = TestAnnotType.symbol.asClass
196+
197+
lazy val BeforeAnnotType: TypeRef = ctx.requiredClassRef("org.junit.Before")
198+
def BeforeAnnotClass(implicit ctx: Context): ClassSymbol = BeforeAnnotType.symbol.asClass
199+
200+
lazy val AfterAnnotType: TypeRef = ctx.requiredClassRef("org.junit.After")
201+
def AfterAnnotClass(implicit ctx: Context): ClassSymbol = AfterAnnotType.symbol.asClass
202+
203+
lazy val BeforeClassAnnotType: TypeRef = ctx.requiredClassRef("org.junit.BeforeClass")
204+
def BeforeClassAnnotClass(implicit ctx: Context): ClassSymbol = BeforeClassAnnotType.symbol.asClass
205+
206+
lazy val AfterClassAnnotType: TypeRef = ctx.requiredClassRef("org.junit.AfterClass")
207+
def AfterClassAnnotClass(implicit ctx: Context): ClassSymbol = AfterClassAnnotType.symbol.asClass
208+
209+
lazy val IgnoreAnnotType: TypeRef = ctx.requiredClassRef("org.junit.Ignore")
210+
def IgnoreAnnotClass(implicit ctx: Context): ClassSymbol = IgnoreAnnotType.symbol.asClass
211+
212+
lazy val BootstrapperType: TypeRef = ctx.requiredClassRef("org.scalajs.junit.Bootstrapper")
213+
214+
lazy val TestMetadataType: TypeRef = ctx.requiredClassRef("org.scalajs.junit.TestMetadata")
215+
216+
lazy val NoSuchMethodExceptionType: TypeRef = ctx.requiredClassRef("java.lang.NoSuchMethodException")
217+
218+
lazy val FutureType: TypeRef = ctx.requiredClassRef("scala.concurrent.Future")
219+
def FutureClass(implicit ctx: Context): ClassSymbol = FutureType.symbol.asClass
220+
221+
private lazy val FutureModule_successfulR = ctx.requiredModule("scala.concurrent.Future").requiredMethodRef("successful")
222+
def FutureModule_successful(implicit ctx: Context): Symbol = FutureModule_successfulR.symbol
223+
224+
private lazy val SuccessModule_applyR = ctx.requiredModule("scala.util.Success").requiredMethodRef(nme.apply)
225+
def SuccessModule_apply(implicit ctx: Context): Symbol = SuccessModule_applyR.symbol
226+
}
227+
192228
}
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
package dotty.tools.backend.sjs
2+
3+
import scala.annotation.tailrec
4+
5+
import dotty.tools.dotc._
6+
7+
import dotty.tools.dotc.core._
8+
import Constants._
9+
import Contexts._
10+
import Decorators._
11+
import Flags._
12+
import Names._
13+
import NameOps._
14+
import Phases._
15+
import Scopes._
16+
import Symbols._
17+
import StdNames._
18+
import Types._
19+
20+
import dotty.tools.dotc.transform.MegaPhase._
21+
22+
/** Generates JUnit bootstrapper objects for Scala.js.
23+
*
24+
* On the JVM, JUnit uses run-time reflection to list and invoke JUnit-related
25+
* methods. They are identified by annotations such as `@Test`, `@Before`,
26+
* etc. In Scala.js, there is no such reflection for methods and annotations,
27+
* so a different strategy is used: this phase performs the necessary
28+
* inspections at compile-time, and generates a so-called bootstrapper object
29+
* where all those metadata have been reified.
30+
*
31+
* With an example: given the following JUnit test class:
32+
*
33+
* ```
34+
* class MyTest {
35+
* @Before def myBefore(): Unit = ...
36+
* @Before def otherBefore(): Unit = ...
37+
*
38+
* @Test def syncTest(): Unit = ...
39+
* @Test def asyncTest(): Future[Try[Unit]] = ...
40+
*
41+
* @Ignore @Test def ignoredTest(): Unit = ...
42+
* }
43+
*
44+
* object MyTest {
45+
* @AfterClass def myAfterClass(): Unit = ...
46+
* }
47+
* ```
48+
*
49+
* this phase generates the following bootstrapper module class:
50+
*
51+
* ```
52+
* object MyTest$scalajs$junit$bootstrapper extends Object with Bootstrapper {
53+
* def beforeClass(): Unit = {
54+
* // nothing, since there is no @BeforeClass method in object MyTest
55+
* }
56+
*
57+
* def afterClass(): Unit = {
58+
* MyTest.myAfterClass()
59+
* }
60+
*
61+
* def before(instance: Object): Unit = {
62+
* // typically 0 or 1, but also support 2 or more
63+
* instance.asInstanceOf[MyTest].myBefore()
64+
* instance.asInstanceOf[MyTest].otherBefore()
65+
* }
66+
*
67+
* def after(instance: Object): Unit = {
68+
* // nothing, since there is no @After method in class MyTest
69+
* }
70+
*
71+
* def tests(): Array[TestMetadata] = Array(
72+
* new TestMetadata("syncTest", false, new org.junit.Test()),
73+
* new TestMetadata("asyncTest", false, new org.junit.Test()),
74+
* new TestMetadata("ignoredTest", true, new org.junit.Test()),
75+
* )
76+
*
77+
* def invokeTest(instance: Object, name: String): Future[Unit] = {
78+
* val castInstance: MyTest = instance.asInstanceOf[MyTest]
79+
* if ("syncTest".equals(name))
80+
* Future.successful(scala.util.Success(castInstance.syncTest()))
81+
* else if ("asyncTest".equals(name))
82+
* castInstance.asyncTest() // asyncTest() already returns a Future[Try[Unit]]
83+
* else if ("ignoredTest".equals(name))
84+
* Future.successful(scala.util.Success(castInstance.ignoredTest()))
85+
* else
86+
* throw new NoSuchMethodException(name)
87+
* }
88+
*
89+
* def newInstance(): Object = new MyTest()
90+
* }
91+
* ```
92+
*
93+
* Note that the support for test methods returning `Future`s is specific to
94+
* Scala.js, and not advertised as a public feature. It is necessary to test
95+
* some things in Scala.js itself, but outside users should use a testing
96+
* framework with official asynchronous support instead.
97+
*
98+
* Because `Booststrapper` is annotated with `@EnableReflectiveInstantiation`,
99+
* the run-time implementation of JUnit for Scala.js can load the boostrapper
100+
* module using `scala.scalajs.reflect.Reflect`, and then use the methods of
101+
* Bootstrapper, which are implemented in the bootstrapper object, to perform
102+
* test discovery and invocation.
103+
*
104+
* TODO At the moment, this phase does not handle `@Test` annotations with
105+
* parameters, notably the expected exception class. This should be handled at
106+
* some point in the future.
107+
*/
108+
class JUnitBootstrappers extends MiniPhase {
109+
import JUnitBootstrappers._
110+
import ast.tpd._
111+
import JSDefinitions.jsdefn
112+
113+
def phaseName: String = "junitBootstrappers"
114+
115+
override def isEnabled(implicit ctx: Context): Boolean =
116+
super.isEnabled && ctx.settings.scalajs.value
117+
118+
// The actual transform -------------------------------
119+
120+
override def transformPackageDef(tree: PackageDef)(implicit ctx: Context): Tree = {
121+
val junitdefn = jsdefn.junit
122+
123+
@tailrec
124+
def hasTests(sym: ClassSymbol): Boolean = {
125+
sym.info.decls.exists(m => m.is(Method) && m.hasAnnotation(junitdefn.TestAnnotClass)) ||
126+
sym.superClass.exists && hasTests(sym.superClass.asClass)
127+
}
128+
129+
def isTestClass(sym: Symbol): Boolean = {
130+
sym.isClass &&
131+
!sym.is(ModuleClass | Abstract | Trait) &&
132+
hasTests(sym.asClass)
133+
}
134+
135+
val bootstrappers = tree.stats.collect {
136+
case clDef: TypeDef if isTestClass(clDef.symbol) =>
137+
genBootstrapper(clDef.symbol.asClass)
138+
}
139+
140+
if (bootstrappers.isEmpty) tree
141+
else cpy.PackageDef(tree)(tree.pid, tree.stats ::: bootstrappers)
142+
}
143+
144+
private def genBootstrapper(testClass: ClassSymbol)(
145+
implicit ctx: Context): TypeDef = {
146+
147+
val junitdefn = jsdefn.junit
148+
149+
/* The name of the boostrapper module. It is derived from the test class name by
150+
* appending a specific suffix string mandated "by spec". It will indeed also be
151+
* computed as such at run-time by the Scala.js JUnit Runtime support. Therefore,
152+
* it must *not* be a dotc semantic name.
153+
*/
154+
val bootstrapperName = (testClass.name ++ "$scalajs$junit$bootstrapper").toTermName
155+
156+
val owner = testClass.owner
157+
val moduleSym = ctx.newCompleteModuleSymbol(owner, bootstrapperName,
158+
Synthetic, Synthetic,
159+
List(defn.ObjectType, junitdefn.BootstrapperType), newScope,
160+
coord = testClass.span, assocFile = testClass.assocFile).entered
161+
val classSym = moduleSym.moduleClass.asClass
162+
163+
val constr = genConstructor(classSym)
164+
165+
val testMethods = annotatedMethods(testClass, junitdefn.TestAnnotClass)
166+
167+
val defs = List(
168+
genCallOnModule(classSym, junitNme.beforeClass, testClass.companionModule, junitdefn.BeforeClassAnnotClass),
169+
genCallOnModule(classSym, junitNme.afterClass, testClass.companionModule, junitdefn.AfterClassAnnotClass),
170+
genCallOnParam(classSym, junitNme.before, testClass, junitdefn.BeforeAnnotClass),
171+
genCallOnParam(classSym, junitNme.after, testClass, junitdefn.AfterAnnotClass),
172+
genTests(classSym, testMethods),
173+
genInvokeTest(classSym, testClass, testMethods),
174+
genNewInstance(classSym, testClass)
175+
)
176+
177+
sbt.APIUtils.registerDummyClass(classSym)
178+
179+
ClassDef(classSym, constr, defs)
180+
}
181+
182+
private def genConstructor(owner: ClassSymbol)(implicit ctx: Context): DefDef = {
183+
val sym = ctx.newDefaultConstructor(owner).entered
184+
DefDef(sym, {
185+
Block(
186+
Super(This(owner), nme.EMPTY.toTypeName, inConstrCall = true).select(defn.ObjectClass.primaryConstructor).appliedToNone :: Nil,
187+
unitLiteral
188+
)
189+
})
190+
}
191+
192+
private def genCallOnModule(owner: ClassSymbol, name: TermName, module: Symbol, annot: Symbol)(implicit ctx: Context): DefDef = {
193+
val sym = ctx.newSymbol(owner, name, Synthetic | Method,
194+
MethodType(Nil, Nil, defn.UnitType)).entered
195+
196+
DefDef(sym, {
197+
if (module.exists) {
198+
val calls = annotatedMethods(module.moduleClass.asClass, annot)
199+
.map(m => Apply(ref(module).select(m), Nil))
200+
Block(calls, unitLiteral)
201+
} else {
202+
unitLiteral
203+
}
204+
})
205+
}
206+
207+
private def genCallOnParam(owner: ClassSymbol, name: TermName, testClass: ClassSymbol, annot: Symbol)(implicit ctx: Context): DefDef = {
208+
val sym = ctx.newSymbol(owner, name, Synthetic | Method,
209+
MethodType(junitNme.instance :: Nil, defn.ObjectType :: Nil, defn.UnitType)).entered
210+
211+
DefDef(sym, { (paramRefss: List[List[Tree]]) =>
212+
val List(List(instanceParamRef)) = paramRefss
213+
val calls = annotatedMethods(testClass, annot)
214+
.map(m => Apply(instanceParamRef.cast(testClass.typeRef).select(m), Nil))
215+
Block(calls, unitLiteral)
216+
})
217+
}
218+
219+
private def genTests(owner: ClassSymbol, tests: List[Symbol])(
220+
implicit ctx: Context): DefDef = {
221+
222+
val junitdefn = jsdefn.junit
223+
224+
val sym = ctx.newSymbol(owner, junitNme.tests, Synthetic | Method,
225+
MethodType(Nil, defn.ArrayOf(junitdefn.TestMetadataType))).entered
226+
227+
DefDef(sym, {
228+
val metadata = for (test <- tests) yield {
229+
val name = Literal(Constant(test.name.toString))
230+
val ignored = Literal(Constant(test.hasAnnotation(junitdefn.IgnoreAnnotClass)))
231+
// TODO Handle @Test annotations with arguments
232+
// val reifiedAnnot = New(mydefn.TestAnnotType, test.getAnnotation(mydefn.TestAnnotClass).get.arguments)
233+
val testAnnot = test.getAnnotation(junitdefn.TestAnnotClass).get
234+
if (testAnnot.arguments.nonEmpty)
235+
ctx.error("@Test annotations with arguments are not yet supported in Scala.js for dotty", testAnnot.tree.sourcePos)
236+
val noArgConstr = junitdefn.TestAnnotType.member(nme.CONSTRUCTOR).suchThat(_.info.paramInfoss.head.isEmpty).symbol.asTerm
237+
val reifiedAnnot = New(junitdefn.TestAnnotType, noArgConstr, Nil)
238+
New(junitdefn.TestMetadataType, List(name, ignored, reifiedAnnot))
239+
}
240+
JavaSeqLiteral(metadata, TypeTree(junitdefn.TestMetadataType))
241+
})
242+
}
243+
244+
private def genInvokeTest(owner: ClassSymbol, testClass: ClassSymbol, tests: List[Symbol])(
245+
implicit ctx: Context): DefDef = {
246+
247+
val junitdefn = jsdefn.junit
248+
249+
val sym = ctx.newSymbol(owner, junitNme.invokeTest, Synthetic | Method,
250+
MethodType(List(junitNme.instance, junitNme.name), List(defn.ObjectType, defn.StringType), junitdefn.FutureType)).entered
251+
252+
DefDef(sym, { (paramRefss: List[List[Tree]]) =>
253+
val List(List(instanceParamRef, nameParamRef)) = paramRefss
254+
val castInstanceSym = ctx.newSymbol(sym, junitNme.castInstance, Synthetic, testClass.typeRef, coord = owner.span)
255+
Block(
256+
ValDef(castInstanceSym, instanceParamRef.cast(testClass.typeRef)) :: Nil,
257+
tests.foldRight[Tree] {
258+
val tp = junitdefn.NoSuchMethodExceptionType
259+
val constr = tp.member(nme.CONSTRUCTOR).suchThat { c =>
260+
c.info.paramInfoss.head.size == 1 &&
261+
c.info.paramInfoss.head.head.isRef(defn.StringClass)
262+
}.symbol.asTerm
263+
Throw(New(tp, constr, nameParamRef :: Nil))
264+
} { (test, next) =>
265+
If(Literal(Constant(test.name.toString)).select(defn.Any_equals).appliedTo(nameParamRef),
266+
genTestInvocation(testClass, test, ref(castInstanceSym)),
267+
next)
268+
}
269+
)
270+
})
271+
}
272+
273+
private def genTestInvocation(testClass: ClassSymbol, testMethod: Symbol, instance: Tree)(
274+
implicit ctx: Context): Tree = {
275+
276+
val junitdefn = jsdefn.junit
277+
278+
val resultType = testMethod.info.resultType
279+
if (resultType.isRef(defn.UnitClass)) {
280+
val newSuccess = ref(junitdefn.SuccessModule_apply).appliedTo(ref(defn.BoxedUnit_UNIT))
281+
Block(
282+
instance.select(testMethod).appliedToNone :: Nil,
283+
ref(junitdefn.FutureModule_successful).appliedTo(newSuccess)
284+
)
285+
} else if (resultType.isRef(junitdefn.FutureClass)) {
286+
instance.select(testMethod).appliedToNone
287+
} else {
288+
// We lie in the error message to not expose that we support async testing.
289+
ctx.error("JUnit test must have Unit return type", testMethod.sourcePos)
290+
EmptyTree
291+
}
292+
}
293+
294+
private def genNewInstance(owner: ClassSymbol, testClass: ClassSymbol)(implicit ctx: Context): DefDef = {
295+
val sym = ctx.newSymbol(owner, junitNme.newInstance, Synthetic | Method,
296+
MethodType(Nil, defn.ObjectType)).entered
297+
298+
DefDef(sym, New(testClass.typeRef, Nil))
299+
}
300+
301+
private def castParam(param: Symbol, clazz: Symbol)(implicit ctx: Context): Tree =
302+
ref(param).cast(clazz.typeRef)
303+
304+
private def annotatedMethods(owner: ClassSymbol, annot: Symbol)(implicit ctx: Context): List[Symbol] =
305+
owner.info.decls.filter(m => m.is(Method) && m.hasAnnotation(annot))
306+
}
307+
308+
object JUnitBootstrappers {
309+
310+
private object junitNme {
311+
val beforeClass: TermName = termName("beforeClass")
312+
val afterClass: TermName = termName("afterClass")
313+
val before: TermName = termName("before")
314+
val after: TermName = termName("after")
315+
val tests: TermName = termName("tests")
316+
val invokeTest: TermName = termName("invokeTest")
317+
val newInstance: TermName = termName("newInstance")
318+
319+
val instance: TermName = termName("instance")
320+
val name: TermName = termName("name")
321+
val castInstance: TermName = termName("castInstance")
322+
}
323+
324+
}

compiler/src/dotty/tools/dotc/Compiler.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class Compiler {
112112
new ExpandPrivate, // Widen private definitions accessed from nested classes
113113
new RestoreScopes, // Repair scopes rendered invalid by moving definitions in prior phases of the group
114114
new SelectStatic, // get rid of selects that would be compiled into GetStatic
115+
new sjs.JUnitBootstrappers, // Generate JUnit-specific bootstrapper classes for Scala.js (not enabled by default)
115116
new CollectEntryPoints, // Find classes with main methods
116117
new CollectSuperCalls) :: // Find classes that are called with super
117118
Nil

0 commit comments

Comments
 (0)