Skip to content

Commit a93b043

Browse files
authored
Merge pull request #30 from cucumber/classes-per-scenario
Instantiate classes per scenario
2 parents 4c4a193 + c0a78e9 commit a93b043

39 files changed

+1205
-293
lines changed

CHANGELOG.md

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

2929
### Fixed
3030

31+
- [Core] Instantiate glue classes per scenario ([#1](https://github.com/cucumber/cucumber-jvm-scala/issues/1) Gaël Jourdan-Weil)
32+
3133
### Security
3234

3335
## [4.7.1] (2019-08-01)
@@ -96,3 +98,16 @@ See also the [CHANGELOG](https://github.com/cucumber/cucumber-jvm/blob/master/CH
9698

9799
- [Core] Update `cucumber-core` dependency to 4.1.0 (Glib Briia)
98100
- [Build] Update Scala versions to 2.11.12 and 2.12.7 ([#11](https://github.com/cucumber/cucumber-jvm-scala/issues/11) Arturas Smorgun)
101+
102+
<!-- References -->
103+
[Unreleased]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.7.1...master
104+
[4.7.1]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.7.0...v4.7.1
105+
[4.7.0]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.6.0...v4.7.0
106+
[4.6.0]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.5.4...v4.6.0
107+
[4.5.4]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.5.3...v4.5.4
108+
[4.5.3]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.4.0...v4.5.3
109+
[4.4.0]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.3.1...v4.4.0
110+
[4.3.1]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.3.0...v4.3.1
111+
[4.3.0]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.2.6...v4.3.0
112+
[4.2.6]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.2.0...v4.2.6
113+
[4.2.0]: https://github.com/cucumber/cucumber-jvm-scala/compare/v4.1.0...v4.2.0

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,10 @@ Any contribution is welcome:
4242
- developing a new feature
4343

4444
Please use this Github project for contributing, either through an issue or a Pull Request.
45+
46+
### Documentation
47+
48+
These pages aim to help Cucumber Scala developers understand the codebase.
49+
50+
- [Scala implementation details](docs/scala_implementation.md)
51+
- [Project structure](docs/project_structure.md)

docs/project_structure.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Project structure
2+
3+
The Cucumber Scala project is a Maven multimodule project:
4+
- `scala` module: contains the codebase of the Cucumber Scala implementation
5+
- `scala_2.11` submodule: build for Scala 2.11.x
6+
- `scala_2.12` submodule: build for Scala 2.12.x
7+
- `scala_2.13` submodule: build for Scala 2.13.x
8+
- `examples` module: contains a sample project
9+
10+
## Cross compilation
11+
12+
The cross compilation for the different Scala versions is handled with 3 different Maven projects: the submodules of the `scala` module.
13+
14+
Each project has a different Scala version as dependency:
15+
```xml
16+
<dependency>
17+
<groupId>org.scala-lang</groupId>
18+
<artifactId>scala-compiler</artifactId>
19+
<version>${scala.2.13.version}</version>
20+
<scope>provided</scope>
21+
</dependency>
22+
```
23+
24+
To not copy/paste the sources across the 3 projects, the sources are put in a separated folder called `sources` in the `scala` module.
25+
Each project uses it by defining the following properties:
26+
```xml
27+
<sourceDirectory>../sources/src/main/scala</sourceDirectory>
28+
<resources>
29+
<resource>
30+
<directory>../sources/src/main/resources</directory>
31+
</resource>
32+
</resources>
33+
<testSourceDirectory>../sources/src/test/scala</testSourceDirectory>
34+
<testResources>
35+
<testResource>
36+
<directory>../sources/src/test/resources</directory>
37+
</testResource>
38+
</testResources>
39+
```
40+
41+
**Note:** when using your favorite IDE, you might have to "close" or "unload" 2 of the 3 projects.
42+
Some IDE are not able to handle shared sources because a source path can be attached to a single IDE project.
43+
If so, only loading the latest (`scala_2.13` project) is recommended.
44+
45+
## Language traits generation
46+
47+
The language traits (`io.cucumber.scala.EN` for instance) are generated automatically using
48+
a Groovy script at compile time.
49+
50+
See in `sources/src/main/groovy/` folder.

docs/scala_implementation.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Scala implementation details
2+
3+
This page covers some details about the Cucumber Scala implementation.
4+
5+
## Running a Cucumber test
6+
7+
### Backend
8+
9+
From Cucumber core perspective, the entrypoint of a Cucumber implementation is what is called "backend".
10+
11+
The `BackendServiceLoader` core service looks for a `BackendProviderService` implementation.
12+
Ours is defined in the class `ScalaBackendProviderService`.
13+
14+
The implementing class also has to be registered as a "Java Service" in the `META-INF/services/io.cucumber.core.backend.BackendProviderService` file (in the `resources` folder).
15+
16+
### Loading the glue
17+
18+
When a Cucumber test starts, a Cucumber Runner starts and a `ScalaBackend` instance is created.
19+
The `ScalaBackend` instance will be used for running all the scenarios which are part of the test (defined by the _features path_ and the _glue path_).
20+
21+
The first thing the Runner does is to "load the glue", that is find all the hooks and step definitions and register them.
22+
This is handled by the `ScalaBackend#loadGlue()` method.
23+
24+
#### Scala implementation
25+
26+
In the Cucumber Scala implementation, loading the glue code means:
27+
- finding all the **classes** inheriting `io.cucumber.scala.ScalaDsl` in the _glue path_, and for each:
28+
- add it to the `Container` instance provided by Cucumber Core
29+
- finding all the **objects** singletons instances inheriting `io.cucumber.scala.ScalaDsl` in the _glue path_ and for each:
30+
- extract the hooks and step definitions from it
31+
- add the definitions to the `Glue` instance provided by Cucumber Core, as NOT `ScenarioScoped`
32+
33+
Ideally all the glue code should be instantiated further (see next section), this is why we register classes (actually a list of `Class`) to the Container.
34+
But this cannot work for objects because they are by definitions singletons and already instantiated way before Cucumber.
35+
Thus, objects are not registered in the Container and their lifecycle is out of Cucumber scope.
36+
37+
### Running a scenario
38+
39+
For each scenario, the `buildWorld()` method of the backend is called.
40+
This is where the glue code should be initialized.
41+
42+
#### Scala implementation
43+
44+
For each **class** identified when loading the glue:
45+
- an instance is created by the `Lookup` provided by Cucumber Core
46+
- hooks and steps definitions are extracted from it
47+
- definitions are added to the `Glue` instance provided by Cucumber Core, as `ScenarioScoped`
48+
49+
Being `ScenarioScoped` ensure instances are flushed at the end of the scenario and recreated for the next one.
50+
51+
## Scala DSL
52+
53+
The Scala DSL is made in a way that any class instance or object extending it contains what we call a **registry**:
54+
a list of the hooks and step definitions it contains.
55+
This is the purpose of `ScalaDslRegistry`.
56+
57+
The registry is populated when the class instance or the object is created.
58+
Unlike other implementations there is no need to use annotations or reflection here.
59+
This is actually **similar to the Java8/Lambda implementation**.

docs/upgrade_v5.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,17 @@ Before { _ =>
5151
```
5252

5353
See also the [Hooks documentation](hooks.md).
54+
55+
## Under the hood
56+
57+
### Instantiate glue classes per scenario
58+
59+
Before Cucumber Scala 5.x, glue classes (classes extending `ScalaDsl`) were instantiated only once for a test suite.
60+
61+
This means that if you wanted to keep state between steps of your scenarios, you had to make sure the state was not shared to other scenarios by using hooks or manual checks.
62+
63+
Starting from Cucumber Scala 5.x, **each scenario creates new glue class instances**.
64+
65+
You should not notice any change unless you rely on state kept between scenarios in your glue classes.
66+
Please note that this is not the proper way to keep a state.
67+
You might want to use an `object` for this purpose.

scala/pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
<modules>
1515
<module>scala_2.11</module>
16-
<module>scala_2.13</module>
16+
<module>scala_2.12</module>
1717
</modules>
1818

1919
<profiles>
@@ -23,7 +23,7 @@
2323
<jdk>1.8</jdk>
2424
</activation>
2525
<modules>
26-
<module>scala_2.12</module>
26+
<module>scala_2.13</module>
2727
</modules>
2828
</profile>
2929
</profiles>
Lines changed: 21 additions & 4 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.Located
3+
import java.lang.reflect.InvocationTargetException
44

5-
abstract class AbstractGlueDefinition(location: StackTraceElement) extends Located {
5+
import io.cucumber.core.backend.{CucumberInvocationTargetException, Located}
66

7-
def getLocation(): String = {
7+
import scala.util.{Failure, Try}
8+
9+
trait AbstractGlueDefinition extends Located {
10+
11+
val location: StackTraceElement
12+
13+
override def getLocation(): String = {
814
location.toString
915
}
1016

11-
def isDefinedAt(stackTraceElement: StackTraceElement): Boolean = {
17+
override def isDefinedAt(stackTraceElement: StackTraceElement): Boolean = {
1218
location.getFileName != null && location.getFileName == stackTraceElement.getFileName
1319
}
1420

21+
/**
22+
* Executes the block of code and handle failures in the way asked by Cucumber specification: that is throwing a CucumberInvocationTargetException.
23+
*/
24+
protected def executeAsCucumber(block: => Unit): Unit = {
25+
Try(block)
26+
.recoverWith {
27+
case ex => Failure(new CucumberInvocationTargetException(this, new InvocationTargetException(ex)))
28+
}
29+
.get
30+
}
31+
1532
}

scala/sources/src/main/scala/io/cucumber/scala/GlueAdaptor.scala

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@ import io.cucumber.core.backend.Glue
44

55
class GlueAdaptor(glue: Glue) {
66

7-
def addDefinition(registry: ScalaDslRegistry): Unit = {
8-
registry.stepDefinitions.foreach(glue.addStepDefinition)
9-
registry.beforeHooks.foreach(glue.addBeforeHook)
10-
registry.afterHooks.foreach(glue.addAfterHook)
11-
registry.afterStepHooks.foreach(glue.addAfterStepHook)
12-
registry.beforeStepHooks.foreach(glue.addBeforeStepHook)
7+
/**
8+
* Load the step definitions and hooks from a ScalaDsl instance into the glue.
9+
*
10+
* @param registry ScalaDsl instance registry
11+
* @param scenarioScoped true for class instances, false for object singletons
12+
*/
13+
def loadRegistry(registry: ScalaDslRegistry, scenarioScoped: Boolean): Unit = {
14+
registry.stepDefinitions.map(ScalaStepDefinition(_, scenarioScoped)).foreach(glue.addStepDefinition)
15+
registry.beforeHooks.map(ScalaHookDefinition(_, scenarioScoped)).foreach(glue.addBeforeHook)
16+
registry.afterHooks.map(ScalaHookDefinition(_, scenarioScoped)).foreach(glue.addAfterHook)
17+
registry.afterStepHooks.map(ScalaHookDefinition(_, scenarioScoped)).foreach(glue.addAfterStepHook)
18+
registry.beforeStepHooks.map(ScalaHookDefinition(_, scenarioScoped)).foreach(glue.addBeforeStepHook)
1319
}
1420

1521
}

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

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,53 +11,58 @@ import io.cucumber.core.resource.{ClasspathScanner, ClasspathSupport}
1111
import scala.collection.JavaConverters._
1212
import scala.util.Try
1313

14-
class ScalaBackend(classLoaderProvider: Supplier[ClassLoader]) extends Backend {
14+
class ScalaBackend(lookup: Lookup, container: Container, classLoaderProvider: Supplier[ClassLoader]) extends Backend {
1515

1616
private val classFinder = new ClasspathScanner(classLoaderProvider)
1717

18-
private[scala] var scalaGlueInstances: Seq[ScalaDsl] = Nil
18+
private var glueAdaptor: GlueAdaptor = _
19+
private[scala] var scalaGlueClasses: Seq[Class[_ <: ScalaDsl]] = Nil
1920

2021
override def disposeWorld(): Unit = {
21-
scalaGlueInstances = Nil
22+
// Nothing to do
2223
}
2324

2425
override def getSnippet(): Snippet = {
2526
new ScalaSnippet()
2627
}
2728

2829
override def buildWorld(): Unit = {
29-
// Nothing to do
30+
// Instantiate all the glue classes and load the glue code from them
31+
scalaGlueClasses.foreach { glueClass =>
32+
val glueInstance = lookup.getInstance(glueClass)
33+
glueAdaptor.loadRegistry(glueInstance.registry, scenarioScoped = true)
34+
}
3035
}
3136

3237
override def loadGlue(glue: Glue, gluePaths: JList[URI]): Unit = {
3338

39+
glueAdaptor = new GlueAdaptor(glue)
40+
3441
val dslClasses = gluePaths.asScala
3542
.filter(gluePath => ClasspathSupport.CLASSPATH_SCHEME.equals(gluePath.getScheme))
3643
.map(ClasspathSupport.packageName)
3744
.flatMap(basePackageName => classFinder.scanForSubClassesInPackage(basePackageName, classOf[ScalaDsl]).asScala)
3845
.filter(glueClass => !glueClass.isInterface)
39-
.filter(glueClass => glueClass.getConstructors.length > 0)
4046

4147
val (clsClasses, objClasses) = dslClasses.partition(isRegularClass)
4248

49+
// Retrieve Scala objects (singletons)
4350
val objInstances = objClasses.map { cls =>
4451
val instField = cls.getDeclaredField("MODULE$")
4552
instField.setAccessible(true)
4653
instField.get(null).asInstanceOf[ScalaDsl]
4754
}
48-
val clsInstances = clsClasses.map {
49-
_.newInstance()
50-
}
51-
52-
// FIXME we should not create instances above but fill the container like Cucumber Java does
53-
// https://github.com/cucumber/cucumber-jvm-scala/issues/1
54-
//clsClasses.foreach(container.addClass(_))
55-
scalaGlueInstances = objInstances.toSeq ++ clsInstances
5655

57-
val glueAdaptor = new GlueAdaptor(glue)
56+
// Regular Scala classes are added to the container, they will be instantiated by the container depending on its logic
57+
// Object are not because by definition they are singletons
58+
clsClasses.foreach { glueClass =>
59+
container.addClass(glueClass)
60+
scalaGlueClasses = scalaGlueClasses :+ glueClass
61+
}
5862

59-
scalaGlueInstances.foreach { glueInstance =>
60-
glueAdaptor.addDefinition(glueInstance.registry)
63+
// For object, we add the definitions here, once for all
64+
objInstances.foreach { glueInstance =>
65+
glueAdaptor.loadRegistry(glueInstance.registry, scenarioScoped = false)
6166
}
6267

6368
()

scala/sources/src/main/scala/io/cucumber/scala/ScalaBackendProviderService.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import io.cucumber.core.backend.{Backend, BackendProviderService, Container, Loo
77
class ScalaBackendProviderService extends BackendProviderService {
88

99
override def create(lookup: Lookup, container: Container, classLoaderSupplier: Supplier[ClassLoader]): Backend = {
10-
new ScalaBackend(classLoaderSupplier)
10+
new ScalaBackend(lookup, container, classLoaderSupplier)
1111
}
1212

1313
}

0 commit comments

Comments
 (0)