Skip to content

Commit 944e03a

Browse files
Copilotgaeljw
andcommitted
Refactor CucumberSuite to parse features and scenarios as nested ScalaTest suites
Co-authored-by: gaeljw <[email protected]>
1 parent 44465e8 commit 944e03a

File tree

1 file changed

+144
-99
lines changed

1 file changed

+144
-99
lines changed
Lines changed: 144 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package io.cucumber.scalatest
22

3-
import io.cucumber.core.options.RuntimeOptionsBuilder
4-
import io.cucumber.core.runtime.{Runtime => CucumberRuntime}
3+
import io.cucumber.core.feature.FeatureParser
4+
import io.cucumber.core.filter.Filters
5+
import io.cucumber.core.gherkin.{Feature, Pickle}
6+
import io.cucumber.core.options._
7+
import io.cucumber.core.plugin.{PluginFactory, Plugins}
8+
import io.cucumber.core.runtime._
59
import org.scalatest.{Args, Status, Suite}
610

11+
import java.time.Clock
12+
import java.util.function.{Predicate, Supplier}
13+
import scala.jdk.CollectionConverters._
714
import scala.annotation.nowarn
815

916
/** Configuration for Cucumber tests.
@@ -36,6 +43,9 @@ case class CucumberOptions(
3643
* - Environment variables starting with CUCUMBER_
3744
* - System properties starting with cucumber.
3845
*
46+
* Each feature file appears as a nested suite, and each scenario within a
47+
* feature appears as a test within that suite.
48+
*
3949
* Example:
4050
* {{{
4151
* import io.cucumber.scalatest.{CucumberOptions, CucumberSuite}
@@ -59,91 +69,99 @@ trait CucumberSuite extends Suite {
5969

6070
private lazy val classLoader: ClassLoader = getClass.getClassLoader
6171

62-
/** Runs the Cucumber scenarios.
63-
*
64-
* @param testName
65-
* An optional name of one test to run. If None, all relevant tests should
66-
* be run.
67-
* @param args
68-
* the Args for this run
69-
* @return
70-
* a Status object that indicates when all tests started by this method
71-
* have completed, and whether or not a failure occurred.
72-
*/
73-
abstract override def run(
74-
testName: Option[String],
75-
args: Args
76-
): Status = {
72+
private lazy val (features, context, filters) = {
73+
val runtimeOptions = buildRuntimeOptions()
74+
val classLoaderSupplier = new Supplier[ClassLoader] {
75+
override def get(): ClassLoader = classLoader
76+
}
77+
78+
val uuidGeneratorServiceLoader =
79+
new UuidGeneratorServiceLoader(classLoaderSupplier, runtimeOptions)
80+
val bus = SynchronizedEventBus.synchronize(
81+
new TimeServiceEventBus(
82+
Clock.systemUTC(),
83+
uuidGeneratorServiceLoader.loadUuidGenerator()
84+
)
85+
)
86+
87+
val parser = new FeatureParser(bus.generateId _)
88+
val featureSupplier =
89+
new FeaturePathFeatureSupplier(
90+
classLoaderSupplier,
91+
runtimeOptions,
92+
parser
93+
)
94+
val features = featureSupplier.get().asScala.toList
95+
96+
val plugins = new Plugins(new PluginFactory(), runtimeOptions)
97+
val exitStatus = new ExitStatus(runtimeOptions)
98+
plugins.addPlugin(exitStatus)
99+
100+
val objectFactoryServiceLoader =
101+
new ObjectFactoryServiceLoader(classLoaderSupplier, runtimeOptions)
102+
val objectFactorySupplier =
103+
new ThreadLocalObjectFactorySupplier(objectFactoryServiceLoader)
104+
val backendSupplier =
105+
new BackendServiceLoader(classLoaderSupplier, objectFactorySupplier)
106+
val runnerSupplier = new ThreadLocalRunnerSupplier(
107+
runtimeOptions,
108+
bus,
109+
backendSupplier,
110+
objectFactorySupplier
111+
)
112+
113+
val context =
114+
new CucumberExecutionContext(bus, exitStatus, runnerSupplier)
115+
val filters: Predicate[Pickle] = new Filters(runtimeOptions)
116+
117+
plugins.setEventBusOnEventListenerPlugins(bus)
118+
119+
(features, context, filters)
120+
}
121+
122+
override def nestedSuites: collection.immutable.IndexedSeq[Suite] = {
123+
features
124+
.map(feature => new FeatureSuite(feature, context, filters))
125+
.toIndexedSeq
126+
}
127+
128+
override def run(testName: Option[String], args: Args): Status = {
77129
if (testName.isDefined) {
78130
throw new IllegalArgumentException(
79-
"Suite traits implemented by Cucumber do not support running a single test"
131+
"Running a single test by name is not supported in CucumberSuite"
80132
)
81133
}
134+
context.runFeatures(() => super.run(testName, args))
135+
org.scalatest.SucceededStatus
136+
}
82137

83-
val runtimeOptions = buildRuntimeOptions()
84-
val classLoader = getClass.getClassLoader
138+
private def buildRuntimeOptions(): RuntimeOptions = {
139+
val packageName = getClass.getPackage.getName
85140

86-
val runtime = CucumberRuntime
87-
.builder()
88-
.withRuntimeOptions(runtimeOptions)
89-
.withClassLoader(new java.util.function.Supplier[ClassLoader] {
90-
override def get(): ClassLoader = classLoader
91-
})
141+
// Parse options from different sources in order of precedence
142+
val propertiesFileOptions = new CucumberPropertiesParser()
143+
.parse(CucumberProperties.fromPropertiesFile())
92144
.build()
93145

94-
runtime.run()
146+
val annotationOptions = buildProgrammaticOptions(propertiesFileOptions)
95147

96-
val exitStatus = runtime.exitStatus()
97-
if (exitStatus == 0) {
98-
org.scalatest.SucceededStatus
99-
} else {
100-
throw new RuntimeException(
101-
s"Cucumber scenarios failed with exit status: $exitStatus"
102-
)
103-
}
148+
val environmentOptions = new CucumberPropertiesParser()
149+
.parse(CucumberProperties.fromEnvironment())
150+
.build(annotationOptions)
151+
152+
val runtimeOptions = new CucumberPropertiesParser()
153+
.parse(CucumberProperties.fromSystemProperties())
154+
.build(environmentOptions)
155+
156+
runtimeOptions
104157
}
105158

106-
private def buildRuntimeOptions(): io.cucumber.core.options.RuntimeOptions = {
159+
private def buildProgrammaticOptions(
160+
base: RuntimeOptions
161+
): RuntimeOptions = {
107162
val packageName = getClass.getPackage.getName
108163
val builder = new RuntimeOptionsBuilder()
109164

110-
// Parse options from cucumber.properties file on classpath
111-
scala.util.Try {
112-
val propertiesUrl = classLoader.getResource("cucumber.properties")
113-
if (propertiesUrl != null) {
114-
val props = new java.util.Properties()
115-
val is = propertiesUrl.openStream()
116-
try {
117-
props.load(is)
118-
} finally {
119-
is.close()
120-
}
121-
import scala.jdk.CollectionConverters._
122-
props.asScala.foreach { case (key, value) =>
123-
applyProperty(builder, key.toString, value.toString)
124-
}
125-
}
126-
}
127-
128-
// Parse options from environment variables (CUCUMBER_*)
129-
scala.util.Try {
130-
sys.env.foreach { case (key, value) =>
131-
if (key.startsWith("CUCUMBER_")) {
132-
val propertyName = key.substring(9).toLowerCase.replace('_', '.')
133-
applyProperty(builder, "cucumber." + propertyName, value)
134-
}
135-
}
136-
}
137-
138-
// Parse options from system properties (cucumber.*)
139-
scala.util.Try {
140-
sys.props.foreach { case (key, value) =>
141-
if (key.startsWith("cucumber.")) {
142-
applyProperty(builder, key, value)
143-
}
144-
}
145-
}
146-
147165
// Add features (programmatic options take precedence)
148166
val features =
149167
if (cucumberOptions.features.nonEmpty) cucumberOptions.features
@@ -176,35 +194,62 @@ trait CucumberSuite extends Suite {
176194
)
177195
}
178196

179-
builder.build()
197+
builder.build(base)
180198
}
181199

182-
private def applyProperty(
183-
builder: RuntimeOptionsBuilder,
184-
key: String,
185-
value: String
186-
): Unit = {
187-
// Map property keys to builder methods
188-
key match {
189-
case "cucumber.glue" =>
190-
value.split(",").foreach { g =>
191-
builder.addGlue(java.net.URI.create("classpath:" + g.trim))
192-
}
193-
case "cucumber.plugin" =>
194-
value.split(",").foreach { p =>
195-
builder.addPluginName(p.trim)
196-
}
197-
case "cucumber.tags" | "cucumber.filter.tags" =>
198-
builder.addTagFilter(
199-
io.cucumber.tagexpressions.TagExpressionParser.parse(value)
200-
)
201-
case "cucumber.features" =>
202-
value.split(",").foreach { f =>
203-
builder.addFeature(
204-
io.cucumber.core.feature.FeatureWithLines.parse(f.trim)
205-
)
200+
private class FeatureSuite(
201+
feature: Feature,
202+
context: CucumberExecutionContext,
203+
filters: Predicate[Pickle]
204+
) extends Suite {
205+
206+
override def suiteName: String =
207+
feature.getName.orElse("EMPTY_NAME")
208+
209+
override def nestedSuites: collection.immutable.IndexedSeq[Suite] = {
210+
feature
211+
.getPickles()
212+
.asScala
213+
.filter(filters.test)
214+
.map(pickle => new PickleSuite(feature, pickle, context))
215+
.toIndexedSeq
216+
}
217+
218+
override def run(testName: Option[String], args: Args): Status = {
219+
context.beforeFeature(feature)
220+
super.run(testName, args)
221+
}
222+
}
223+
224+
private class PickleSuite(
225+
feature: Feature,
226+
pickle: Pickle,
227+
context: CucumberExecutionContext
228+
) extends Suite {
229+
230+
override def suiteName: String = pickle.getName
231+
232+
override def testNames: Set[String] = Set(pickle.getName)
233+
234+
override protected def runTest(
235+
testName: String,
236+
args: Args
237+
): Status = {
238+
var testFailed: Option[Throwable] = None
239+
240+
context.runTestCase(runner => {
241+
try {
242+
runner.runPickle(pickle)
243+
} catch {
244+
case ex: Throwable =>
245+
testFailed = Some(ex)
206246
}
207-
case _ => // Ignore unknown properties
247+
})
248+
249+
testFailed match {
250+
case Some(ex) => throw ex
251+
case None => org.scalatest.SucceededStatus
252+
}
208253
}
209254
}
210255
}

0 commit comments

Comments
 (0)