Skip to content

Commit c55996b

Browse files
authored
Allow usage of class with args and dependency injection (#292)
* test: add integration tests for picocontainer * fix: better identification of class vs object As a side effect, this allows to work with classes having args constructor * docs: update CHANGELOG * docs: document DI ability
1 parent 94e2c28 commit c55996b

File tree

14 files changed

+242
-12
lines changed

14 files changed

+242
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ See also the [CHANGELOG](https://github.com/cucumber/cucumber-jvm/blob/master/CH
1919

2020
### Fixed
2121

22+
- [Scala] Integration with DI modules like `cucumber-picocontainer` is now working
23+
2224
## [8.3.3] (2022-05-04)
2325

2426
### Changed

build.sbt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ lazy val root = (project in file("."))
6666
cucumberScala.projectRefs ++
6767
integrationTestsCommon.projectRefs ++
6868
integrationTestsJackson.projectRefs ++
69+
integrationTestsPicoContainer.projectRefs ++
6970
examples.projectRefs: _*
7071
)
7172

@@ -151,6 +152,21 @@ lazy val integrationTestsJackson =
151152
.dependsOn(cucumberScala % Test)
152153
.jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
153154

155+
lazy val integrationTestsPicoContainer =
156+
(projectMatrix in file("integration-tests/picocontainer"))
157+
.settings(commonSettings)
158+
.settings(
159+
name := "integration-tests-picocontainer",
160+
libraryDependencies ++= Seq(
161+
"junit" % "junit" % junitVersion % Test,
162+
"io.cucumber" % "cucumber-junit" % cucumberVersion % Test,
163+
"io.cucumber" % "cucumber-picocontainer" % cucumberVersion % Test
164+
),
165+
publishArtifact := false
166+
)
167+
.dependsOn(cucumberScala % Test)
168+
.jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
169+
154170
// Examples project
155171
lazy val examples = (projectMatrix in file("examples"))
156172
.settings(commonSettings)

cucumber-scala/src/main/scala/io/cucumber/scala/ScalaBackend.scala

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
package io.cucumber.scala
22

3+
import io.cucumber.core.backend._
4+
import io.cucumber.core.resource.{ClasspathScanner, ClasspathSupport}
5+
import io.cucumber.scala.ScalaBackend.isRegularClass
6+
37
import java.lang.reflect.Modifier
48
import java.net.URI
59
import java.util.function.Supplier
610
import java.util.{List => JList}
7-
8-
import io.cucumber.core.backend._
9-
import io.cucumber.core.resource.{ClasspathScanner, ClasspathSupport}
10-
1111
import scala.jdk.CollectionConverters._
12-
import scala.util.Try
12+
import scala.util.{Failure, Try}
13+
14+
object ScalaBackend {
15+
16+
/** @return true if it's a class, false if it's an object
17+
*/
18+
private[scala] def isRegularClass(cls: Class[_]): Try[Boolean] = {
19+
Try {
20+
// Object don't have constructors
21+
cls.getConstructors.headOption
22+
.map(_.getModifiers)
23+
.exists(Modifier.isPublic)
24+
}.recoverWith { case ex: Throwable =>
25+
Failure(new UnknownClassType(cls, ex))
26+
}
27+
}
28+
29+
}
1330

1431
class ScalaBackend(
1532
lookup: Lookup,
@@ -33,8 +50,15 @@ class ScalaBackend(
3350
override def buildWorld(): Unit = {
3451
// Instantiate all the glue classes and load the glue code from them
3552
scalaGlueClasses.foreach { glueClass =>
36-
val glueInstance = lookup.getInstance(glueClass)
37-
glueAdaptor.loadRegistry(glueInstance.registry, scenarioScoped = true)
53+
val glueInstance = Option(lookup.getInstance(glueClass))
54+
glueInstance match {
55+
case Some(glue) =>
56+
glueAdaptor.loadRegistry(glue.registry, scenarioScoped = true)
57+
case None =>
58+
throw new CucumberBackendException(
59+
s"Not able to instantiate class ${glueClass.getName}. Please report this issue to cucumber-scala project."
60+
)
61+
}
3862
}
3963
}
4064

@@ -54,7 +78,9 @@ class ScalaBackend(
5478
)
5579
.filter(glueClass => !glueClass.isInterface)
5680

57-
val (clsClasses, objClasses) = dslClasses.partition(isRegularClass)
81+
// Voluntarily throw exception if not able to identify if it's a class
82+
val (clsClasses, objClasses) =
83+
dslClasses.partition(c => isRegularClass(c).get)
5884

5985
// Retrieve Scala objects (singletons)
6086
val objInstances = objClasses.map { cls =>
@@ -78,8 +104,4 @@ class ScalaBackend(
78104
()
79105
}
80106

81-
private def isRegularClass(cls: Class[_]): Boolean = {
82-
Try(Modifier.isPublic(cls.getConstructor().getModifiers)).getOrElse(false)
83-
}
84-
85107
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.cucumber.scala
2+
3+
import io.cucumber.core.backend.CucumberBackendException
4+
5+
class UnknownClassType(clazz: Class[_], cause: Throwable)
6+
extends CucumberBackendException(
7+
s"Cucumber was not able to handle class ${clazz.getName}. Please report this issue to cucumber-scala project.",
8+
cause
9+
)

cucumber-scala/src/test/scala/io/cucumber/scala/ScalaBackendTest.scala

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package io.cucumber.scala
22

33
import io.cucumber.core.backend._
44
import io.cucumber.scala.steps.classes.{StepsA, StepsB, StepsC}
5+
import io.cucumber.scala.steps.dependencyinjection.{Injected, Injector}
56
import io.cucumber.scala.steps.errors.incorrectclasshooks.IncorrectClassHooksDefinition
67
import io.cucumber.scala.steps.errors.staticclasshooks.StaticClassHooksDefinition
8+
import io.cucumber.scala.steps.objects.StepsInObject
79
import io.cucumber.scala.steps.traits.StepsInTrait
810
import org.junit.Assert.{assertEquals, assertTrue, fail}
911
import org.junit.{Before, Test}
@@ -43,6 +45,9 @@ class ScalaBackendTest {
4345
.thenReturn(new IncorrectClassHooksDefinition())
4446
when(fakeLookup.getInstance(classOf[StaticClassHooksDefinition]))
4547
.thenReturn(new StaticClassHooksDefinition())
48+
when(fakeLookup.getInstance(classOf[Injected])).thenReturn(new Injected)
49+
when(fakeLookup.getInstance(classOf[Injector]))
50+
.thenReturn(new Injector(new Injected))
4651

4752
// Create the instances
4853
backend = new ScalaBackend(fakeLookup, fakeContainer, classLoaderSupplier)
@@ -317,4 +322,66 @@ class ScalaBackendTest {
317322
}
318323
}
319324

325+
@Test
326+
def loadGlueAndBuildWorld_DI(): Unit = {
327+
// Load glue
328+
backend.loadGlue(
329+
fakeGlue,
330+
List(
331+
URI.create("classpath:io/cucumber/scala/steps/dependencyinjection")
332+
).asJava
333+
)
334+
335+
assertEquals(2, backend.scalaGlueClasses.size)
336+
assertTrue(
337+
backend.scalaGlueClasses.toSet == Set(
338+
classOf[Injected],
339+
classOf[Injector]
340+
)
341+
)
342+
343+
verify(fakeContainer, times(2)).addClass(any())
344+
verify(fakeContainer, times(1)).addClass(classOf[Injected])
345+
verify(fakeContainer, times(1)).addClass(classOf[Injector])
346+
347+
// Build world
348+
backend.buildWorld()
349+
350+
verify(fakeLookup, times(2)).getInstance(any())
351+
verify(fakeLookup, times(1)).getInstance(classOf[Injected])
352+
verify(fakeLookup, times(1)).getInstance(classOf[Injector])
353+
354+
// Building the world a second time should create new instances
355+
backend.disposeWorld()
356+
backend.buildWorld()
357+
358+
verify(fakeLookup, times(4)).getInstance(any())
359+
verify(fakeLookup, times(2)).getInstance(classOf[Injected])
360+
verify(fakeLookup, times(2)).getInstance(classOf[Injector])
361+
362+
}
363+
364+
@Test
365+
def isRegularClass_class(): Unit = {
366+
assertEquals(true, ScalaBackend.isRegularClass(classOf[StepsA]).get)
367+
}
368+
369+
@Test
370+
def isRegularClass_class_with_trait(): Unit = {
371+
assertEquals(true, ScalaBackend.isRegularClass(classOf[StepsInTrait]).get)
372+
}
373+
374+
@Test
375+
def isRegularClass_class_with_args(): Unit = {
376+
assertEquals(true, ScalaBackend.isRegularClass(classOf[Injector]).get)
377+
}
378+
379+
@Test
380+
def isRegularClass_object(): Unit = {
381+
assertEquals(
382+
false,
383+
ScalaBackend.isRegularClass(StepsInObject.getClass).get
384+
)
385+
}
386+
320387
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.cucumber.scala.steps.dependencyinjection
2+
3+
import io.cucumber.scala.{EN, ScalaDsl}
4+
5+
class Injected extends ScalaDsl with EN {
6+
7+
var x: String = _
8+
9+
Given("""injected steps""") { () =>
10+
// Nothing
11+
}
12+
13+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.cucumber.scala.steps.dependencyinjection
2+
3+
import io.cucumber.scala.{EN, ScalaDsl}
4+
5+
class Injector(injected: Injected) extends ScalaDsl with EN {
6+
7+
Then("""Injector""") { () =>
8+
println(injected.x)
9+
}
10+
11+
}

docs/usage.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ You can also define glue code in **objects**.
5050
Be aware that by definition objects are singleton and if your glue code is stateful you will probably have "state conflicts"
5151
between your scenarios if you use shared variables from objects.
5252

53+
### Using dependency-injection
54+
55+
Starting with cucumber-scala 8.4, it is possible to use DI modules in order to share state between steps.
56+
57+
You can for instance have the following definition:
58+
```scala
59+
import io.cucumber.scala.{EN, ScalaDsl}
60+
61+
class A extends ScalaDsl with EN {
62+
63+
var input: String = _
64+
65+
Given("""a step defined in class A with arg {string}""") { (arg: String) =>
66+
input = arg
67+
}
68+
69+
}
70+
71+
class B(a: A) extends ScalaDsl with EN {
72+
73+
When("""a step defined in class B uses A""") { () =>
74+
// Do something with a.input
75+
println(a.input)
76+
}
77+
78+
}
79+
```
80+
81+
To make it work, you only need a Cucumber DI module to be added as a dependency of your project
82+
(like `cucumber-picocontainer`, or `cucumber-guice`, or any other provided by Cucumber).
83+
5384
## Running Cucumber tests
5485

5586
See also the Running Cucumber for Java [documentation](https://docs.cucumber.io/docs/cucumber/api/#running-cucumber).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
cucumber.publish.quiet=true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Feature: As Cucumber Scala, I want to be able to have some step classes depend on another one
2+
3+
Scenario: Nominal case
4+
Given a step defined in class DI-A with arg "A"
5+
And a step defined in class DI-B with arg "B"
6+
When a step defined in class DI-C uses them both
7+
Then both values are combined into "AB"

0 commit comments

Comments
 (0)