diff --git a/build.sbt b/build.sbt
index b94ef6dc..1b77e483 100644
--- a/build.sbt
+++ b/build.sbt
@@ -42,6 +42,7 @@ val cucumberVersion = "7.17.0"
 val jacksonVersion = "2.17.0"
 val mockitoScalaVersion = "1.17.31"
 val junitVersion = "4.13.2"
+val scalatestVersion = "3.2.18"
 
 // Projects and settings
 
@@ -64,6 +65,7 @@ lazy val root = (project in file("."))
   )
   .aggregate(
     cucumberScala.projectRefs ++
+      cucumberScalatest.projectRefs ++
       integrationTestsCommon.projectRefs ++
       integrationTestsJackson.projectRefs ++
       integrationTestsPicoContainer.projectRefs ++
@@ -122,6 +124,17 @@ lazy val cucumberScala = (projectMatrix in file("cucumber-scala"))
   )
   .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
 
+lazy val cucumberScalatest = (projectMatrix in file("scalatest"))
+  .settings(commonSettings)
+  .settings(
+    name := "cucumber-scalatest",
+    libraryDependencies ++= Seq(
+      "io.cucumber" % "cucumber-core" % cucumberVersion,
+      "org.scalatest" %% "scalatest" % scalatestVersion
+    )
+  )
+  .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
+
 // Integration tests
 lazy val integrationTestsCommon =
   (projectMatrix in file("integration-tests/common"))
@@ -167,6 +180,19 @@ lazy val integrationTestsPicoContainer =
     .dependsOn(cucumberScala % Test)
     .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
 
+lazy val integrationTestsScalatest =
+  (projectMatrix in file("integration-tests/scalatest"))
+    .settings(commonSettings)
+    .settings(
+      name := "integration-tests-scalatest",
+      libraryDependencies ++= Seq(
+        "org.scalatest" %% "scalatest" % scalatestVersion % Test
+      ),
+      publishArtifact := false
+    )
+    .dependsOn(cucumberScala % Test, cucumberScalatest % Test)
+    .jvmPlatform(scalaVersions = Seq(scala3, scala213, scala212))
+
 // Examples project
 lazy val examples = (projectMatrix in file("examples"))
   .settings(commonSettings)
@@ -200,12 +226,12 @@ releaseProcess := Seq[ReleaseStep](
   runTest,
   setReleaseVersion,
   // the 2 following steps are part of the Cucumber release process
-  //commitReleaseVersion,
-  //tagRelease,
+  // commitReleaseVersion,
+  // tagRelease,
   releaseStepCommandAndRemaining("publishSigned"),
   releaseStepCommand("sonatypeBundleRelease"),
   setNextVersion
   // the 2 following steps are part of the Cucumber release process
-  //commitNextVersion,
-  //pushChanges
+  // commitNextVersion,
+  // pushChanges
 )
diff --git a/integration-tests/scalatest/src/test/resources/cukes/cukes.feature b/integration-tests/scalatest/src/test/resources/cukes/cukes.feature
new file mode 100644
index 00000000..79e23c91
--- /dev/null
+++ b/integration-tests/scalatest/src/test/resources/cukes/cukes.feature
@@ -0,0 +1,89 @@
+Feature: Cukes
+
+  Scenario: in the belly
+    Given I have 4 "cukes" in my belly
+    Then I am "happy"
+
+  Scenario: Int in the belly
+    Given I have eaten an int 100
+    Then I should have one hundred in my belly
+
+  Scenario: Long in the belly
+    Given I have eaten a long 100
+    Then I should have long one hundred in my belly
+
+  Scenario: String in the belly
+    Given I have eaten "numnumnum"
+    Then I should have numnumnum in my belly
+
+  Scenario: Double in the belly
+    Given I have eaten 1.5 doubles
+    Then I should have one and a half doubles in my belly
+
+  Scenario: Float in the belly
+    Given I have eaten 1.5 floats
+    Then I should have one and a half floats in my belly
+
+  Scenario: Short in the belly
+    Given I have eaten a short 100
+    Then I should have short one hundred in my belly
+
+  Scenario: Byte in the belly
+    Given I have eaten a byte 2
+    Then I should have two byte in my belly
+
+  Scenario: BigDecimal in the belly
+    Given I have eaten 1.5 big decimals
+    Then I should have one and a half big decimals in my belly
+
+  Scenario: BigInt in the belly
+    Given I have eaten 10 big int
+    Then I should have a ten big int in my belly
+
+  Scenario: Char in the belly
+    Given I have eaten char 'C'
+    Then I should have character C in my belly
+
+  Scenario: Boolean in the belly
+    Given I have eaten boolean true
+    Then I should have truth in my belly
+
+  Scenario: DataTable in the belly
+    Given I have the following foods :
+      | FOOD   | CALORIES |
+      | cheese |      500 |
+      | burger |     1000 |
+      | fries  |      750 |
+    Then I am "definitely happy"
+    And have eaten 2250.0 calories today
+
+  Scenario: DataTable with args in the belly
+    Given I have a table the sum of all rows should be 400 :
+      | ROW |
+      |  20 |
+      |  80 |
+      | 300 |
+
+  Scenario: Argh! a snake - to be custom mapped
+    Given I see in the distance ... =====>
+    Then I have a snake of length 6 moving east
+    And I see in the distance ... <====================
+    Then I have a snake of length 21 moving west
+
+  Scenario: Custom object with string constructor
+    Given I have a person Bob
+    Then he should say "Hello, I'm Bob!"
+
+  Scenario: Custom objects in the belly
+    Given I have eaten the following cukes
+      | Color | Number |
+      | Green |      1 |
+      | Red   |      3 |
+      | Blue  |      2 |
+    Then I should have eaten 6 cukes
+    And they should have been Green, Red, Blue
+
+  Scenario: Did you know that we can handle call by name and zero arity
+    Given I drink gin and vermouth
+    When I shake my belly
+    Then I should have lots of martinis
diff --git a/integration-tests/scalatest/src/test/scala/RunCukesTest.scala b/integration-tests/scalatest/src/test/scala/RunCukesTest.scala
new file mode 100644
index 00000000..7663d63f
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/RunCukesTest.scala
@@ -0,0 +1,10 @@
+package cukes
+
+import io.cucumber.scalatest.{CucumberSuite, CucumberSuiteOptions}
+
+class RunCukesTest extends CucumberSuite with CucumberSuiteOptions {
+
+  override def featuresPath: Seq[String] = Nil
+
+  override def gluePackages: Seq[String] = Nil
+}
diff --git a/integration-tests/scalatest/src/test/scala/StepDefs.scala b/integration-tests/scalatest/src/test/scala/StepDefs.scala
new file mode 100644
index 00000000..d95853cd
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/StepDefs.scala
@@ -0,0 +1,185 @@
+import io.cucumber.datatable.DataTable
+import io.cucumber.scala.{EN, ScalaDsl}
+import model.{Cukes, Person, Snake}
+import org.junit.Assert.assertEquals
+
+import java.util.{List => JList, Map => JMap}
+import scala.annotation.nowarn
+import scala.jdk.CollectionConverters._
+
+/** Test step definitions to exercise Scala cucumber
+  */
+@nowarn
+class CukesStepDefinitions extends ScalaDsl with EN {
+
+  var calorieCount = 0.0
+  var intBelly: Int = 0
+  var longBelly: Long = 0L
+  var stringBelly: String = ""
+  var doubleBelly: Double = 0.0
+  var floatBelly: Float = 0.0f
+  var shortBelly: Short = 0.toShort
+  var byteBelly: Byte = 0.toByte
+  var bigDecimalBelly: BigDecimal = BigDecimal(0)
+  var bigIntBelly: BigInt = BigInt(0)
+  var charBelly: Char = 'A'
+  var boolBelly: Boolean = false
+  var snake: Snake = null
+  var person: Person = null
+  var cukes: JList[Cukes] = null
+  var gin: Int = 13
+  var vermouth: Int = 42
+  var maritinis: Int = 0
+
+  Given("""I have {} {string} in my belly""") { (howMany: Int, what: String) =>
+  }
+
+  Given("""^I have the following foods :$""") { (table: DataTable) =>
+    val maps: JList[JMap[String, String]] =
+      table.asMaps(classOf[String], classOf[String])
+    calorieCount =
+      maps.asScala.map(_.get("CALORIES")).map(_.toDouble).fold(0.0)(_ + _)
+  }
+  And("""have eaten {double} calories today""") { (calories: Double) =>
+    assertEquals(calories, calorieCount, 0.0)
+  }
+
+  Given("""I have eaten an int {int}""") { (arg0: Int) =>
+    intBelly = arg0
+  }
+  Then("""^I should have one hundred in my belly$""") { () =>
+    assertEquals(100, intBelly)
+  }
+
+  Given("""I have eaten a long {long}""") { (arg0: Long) =>
+    longBelly = arg0
+  }
+  Then("""^I should have long one hundred in my belly$""") { () =>
+    assertEquals(100L, longBelly)
+  }
+
+  Given("""^I have eaten "(.*)"$""") { (arg0: String) =>
+    stringBelly = arg0
+  }
+  Then("""^I should have numnumnum in my belly$""") { () =>
+    assertEquals("numnumnum", stringBelly)
+  }
+
+  Given("""I have eaten {double} doubles""") { (arg0: Double) =>
+    doubleBelly = arg0
+  }
+  Then("""^I should have one and a half doubles in my belly$""") { () =>
+    assertEquals(1.5, doubleBelly, 0.0)
+  }
+
+  Given("""I have eaten {} floats""") { (arg0: Float) =>
+    floatBelly = arg0
+  }
+  Then("""^I should have one and a half floats in my belly$""") { () =>
+    assertEquals(1.5f, floatBelly, 0.0)
+  }
+
+  Given("""I have eaten a short {short}""") { (arg0: Short) =>
+    shortBelly = arg0
+  }
+  Then("""^I should have short one hundred in my belly$""") { () =>
+    assertEquals(100.toShort, shortBelly)
+  }
+
+  Given("""I have eaten a byte {byte}""") { (arg0: Byte) =>
+    byteBelly = arg0
+  }
+  Then("""^I should have two byte in my belly$""") { () =>
+    assertEquals(2.toByte, byteBelly)
+  }
+
+  Given("""I have eaten {bigdecimal} big decimals""") {
+    (arg0: java.math.BigDecimal) =>
+      bigDecimalBelly = arg0
+  }
+  Then("""^I should have one and a half big decimals in my belly$""") { () =>
+    assertEquals(BigDecimal(1.5), bigDecimalBelly)
+  }
+
+  Given("""I have eaten {biginteger} big int""") {
+    (arg0: java.math.BigInteger) =>
+      bigIntBelly = arg0.intValue()
+  }
+  Then("""^I should have a ten big int in my belly$""") { () =>
+    assertEquals(BigInt(10), bigIntBelly)
+  }
+
+  Given("""I have eaten char '{char}'""") { (arg0: Char) =>
+    charBelly = 'C'
+  }
+  Then("""^I should have character C in my belly$""") { () =>
+    assertEquals('C', charBelly)
+  }
+
+  Given("""I have eaten boolean {boolean}""") { (arg0: Boolean) =>
+    boolBelly = arg0
+  }
+  Then("""^I should have truth in my belly$""") { () =>
+    assertEquals(true, boolBelly)
+  }
+
+  Given("""I have a table the sum of all rows should be {int} :""") {
+    (value: Int, table: DataTable) =>
+      assertEquals(
+        value,
+        table
+          .asList(classOf[String])
+          .asScala
+          .drop(1)
+          .map(String.valueOf(_: String).toInt)
+          .foldLeft(0)(_ + _)
+      )
+  }
+
+  Given("""I see in the distance ... {snake}""") { (s: Snake) =>
+    snake = s
+  }
+  Then("""^I have a snake of length (\d+) moving (.*)$""") {
+    (size: Int, dir: String) =>
+      assertEquals(size, snake.length)
+      assertEquals(Symbol(dir), snake.direction)
+  }
+
+  Given("""I have a person {person}""") { (p: Person) =>
+    person = p
+  }
+
+  Then("""^he should say \"(.*)\"""") { (s: String) =>
+    assertEquals(person.hello, s)
+  }
+
+  Given("^I have eaten the following cukes$") { (cs: JList[Cukes]) =>
+    cukes = cs
+  }
+
+  Then("""I should have eaten {int} cukes""") { (total: Int) =>
+    assertEquals(total, cukes.asScala.map(_.number).sum)
+  }
+
+  And("^they should have been (.*)$") { (colors: String) =>
+    assertEquals(colors, cukes.asScala.map(_.color).mkString(", "))
+  }
+
+  Given("^I drink gin and vermouth$") { () =>
+    gin = 13
+    vermouth = 42
+  }
+
+  When("^I shake my belly$") { // note the lack of  () =>
+    maritinis += vermouth * gin
+  }
+
+  Then("^I should have lots of martinis$") { () =>
+    assertEquals(13 * 42, maritinis)
+  }
+}
+
+@nowarn
+class ThenDefs extends ScalaDsl with EN {
+  Then("""^I am "([^"]*)"$""") { (arg0: String) => }
+}
diff --git a/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala b/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala
new file mode 100644
index 00000000..4bfd146f
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/TypeRegistryConfiguration.scala
@@ -0,0 +1,39 @@
+import io.cucumber.scala.ScalaDsl
+import model.{Cukes, Person, Snake}
+
+class TypeRegistryConfiguration extends ScalaDsl {
+
+  /** Transforms an ASCII snake into an object, for example:
+    *
+    * {{{
+    *  ====>  becomes Snake(length = 5, direction = 'east)
+    *    ==>  becomes Snake(length = 3, direction = 'east)
+    * }}}
+    */
+  ParameterType("snake", "[=><]+") { s =>
+    val size = s.length
+    val direction = s.toList match {
+      case '<' :: _           => Symbol("west")
+      case l if l.last == '>' => Symbol("east")
+      case _                  => Symbol("unknown")
+    }
+    Snake(size, direction)
+  }
+
+  ParameterType("person", ".+") { s =>
+    Person(s)
+  }
+
+  ParameterType("boolean", "true|false") { s =>
+    s.trim.equals("true")
+  }
+
+  ParameterType("char", ".") { s =>
+    s.charAt(0)
+  }
+
+  DataTableType { (map: Map[String, String]) =>
+    Cukes(map("Number").toInt, map("Color"))
+  }
+
+}
diff --git a/integration-tests/scalatest/src/test/scala/model/Cuke.scala b/integration-tests/scalatest/src/test/scala/model/Cuke.scala
new file mode 100644
index 00000000..806027fb
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/model/Cuke.scala
@@ -0,0 +1,3 @@
+package model
+
+case class Cukes(number: Int, color: String)
diff --git a/integration-tests/scalatest/src/test/scala/model/Person.scala b/integration-tests/scalatest/src/test/scala/model/Person.scala
new file mode 100644
index 00000000..8007e698
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/model/Person.scala
@@ -0,0 +1,13 @@
+package model
+
+/** Test model for a "Person"
+  * @param name
+  *   of person
+  */
+case class Person(name: String) {
+
+  def hello = {
+    "Hello, I'm " + name + "!"
+  }
+
+}
diff --git a/integration-tests/scalatest/src/test/scala/model/Snake.scala b/integration-tests/scalatest/src/test/scala/model/Snake.scala
new file mode 100644
index 00000000..cc7fc21e
--- /dev/null
+++ b/integration-tests/scalatest/src/test/scala/model/Snake.scala
@@ -0,0 +1,10 @@
+package model
+
+/** Test model "Snake" to exercise the custom mapper functionality
+  *
+  * @param length
+  *   of the snake in characters
+  * @param direction
+  *   in which snake is moving 'west, 'east, etc
+  */
+case class Snake(length: Int, direction: Symbol) {}
diff --git a/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala b/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala
new file mode 100644
index 00000000..3bd4d278
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/core/options/CucumberSuiteOptionsParser.scala
@@ -0,0 +1,149 @@
+package io.cucumber.core.options
+
+import io.cucumber.core.exception.CucumberException
+import io.cucumber.core.feature.{FeatureWithLines, GluePath}
+import io.cucumber.core.resource.ClasspathSupport.CLASSPATH_SCHEME_PREFIX
+import io.cucumber.core.snippets.SnippetType
+import io.cucumber.scalatest.CucumberSuiteOptions
+import io.cucumber.tagexpressions.{TagExpressionException, TagExpressionParser}
+
+import java.util.regex.Pattern
+import scala.jdk.OptionConverters.RichOptional
+import scala.util.{Failure, Success, Try}
+
+object CucumberSuiteOptionsParser {
+
+  def unsafeParse[T <: CucumberSuiteOptions](
+      options: T
+  ): RuntimeOptionsBuilder = {
+    parse(options) match {
+      case Success(value) => value
+      case Failure(exception) =>
+        println(
+          "An exception happened while parsing CucumberSuite options. This is likely an issue with Cucumber-Scalatest implementation. Please open an issue on GitHub."
+        )
+        exception.printStackTrace()
+        throw exception
+    }
+  }
+
+  def parse[T <: CucumberSuiteOptions](
+      options: T
+  ): Try[RuntimeOptionsBuilder] = {
+    Try {
+      // TODO verify how getClass behaves with the trait
+      val clazz = options.getClass
+
+      println("aaa")
+
+      val args = new RuntimeOptionsBuilder
+
+      println("bbb")
+
+      if (options.dryRun) {
+        args.setDryRun(true)
+      }
+
+      println("bbb2")
+
+      if (options.monochrome) {
+        args.setMonochrome(true)
+      }
+
+      println("bbb3")
+
+      val tagExpression = options.matchingTags
+      if (tagExpression.nonEmpty) {
+        Try {
+          args.addTagFilter(TagExpressionParser.parse(tagExpression))
+          println("ok tags")
+        }.recover { case tee: TagExpressionException =>
+          println("Invalid tag expression a")
+          throw new IllegalArgumentException(
+            String.format(
+              "Invalid tag expression at '%s'",
+              clazz.getName
+            ),
+            tee
+          )
+        }
+      }
+
+      println("ccc")
+
+      options.plugins.foreach(args.addPluginName)
+
+      if (options.publish) {
+        args.setPublish(true)
+      }
+
+      options.names.foreach(name => args.addNameFilter(Pattern.compile(name)))
+
+      val snippetType = options.snippets match {
+        case CucumberSuiteOptions.SnippetType.UNDERSCORE =>
+          SnippetType.UNDERSCORE
+        case CucumberSuiteOptions.SnippetType.CAMELCASE => SnippetType.CAMELCASE
+      }
+      args.setSnippetType(snippetType)
+
+      val hasExtraGlue = options.extraGluePackages.nonEmpty
+      val hasGlue = options.gluePackages.nonEmpty
+
+      println("glue")
+
+      if (hasExtraGlue && hasGlue) {
+        throw new CucumberException(
+          "gluePackages and extraGluePackages cannot be specified at the same time"
+        )
+      }
+
+      if (hasExtraGlue) {
+        options.extraGluePackages.foreach(glue =>
+          args.addGlue(GluePath.parse(glue))
+        )
+      }
+
+      if (hasGlue) {
+        options.gluePackages.foreach(glue => args.addGlue(GluePath.parse(glue)))
+      } else {
+        args.addGlue(GluePath.parse(packageName(clazz)))
+      }
+
+      if (options.featuresPath.nonEmpty) {
+        options.featuresPath.foreach { feature =>
+          val parsed = FeatureWithLinesOrRerunPath.parse(feature)
+          parsed.getFeaturesToRerun.toScala.foreach(features =>
+            args.addRerun(features)
+          )
+          parsed.getFeatureWithLines.toScala.foreach(features =>
+            args.addFeature(features)
+          )
+        }
+      } else {
+        val packageName = packagePath(clazz)
+        val featureWithLines = FeatureWithLines.parse(packageName)
+        args.addFeature(featureWithLines)
+      }
+
+      // TODO
+//      args.setObjectFactoryClass(options.objectFactory)
+//      args.setUuidGeneratorClass(options.uuidGenerator)
+
+      println("args")
+
+      args
+    }
+  }
+
+  private def packagePath(clazz: Class[_]): String = {
+    val name = packageName(clazz)
+    if (name.isEmpty) return CLASSPATH_SCHEME_PREFIX + "/"
+    CLASSPATH_SCHEME_PREFIX + "/" + name.replace('.', '/')
+  }
+
+  private def packageName(clazz: Class[_]): String = {
+    val className = clazz.getName
+    className.substring(0, Math.max(0, className.lastIndexOf('.')))
+  }
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala
new file mode 100644
index 00000000..156edb28
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuite.scala
@@ -0,0 +1,147 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.eventbus.EventBus
+import io.cucumber.core.feature.FeatureParser
+import io.cucumber.core.filter.Filters
+import io.cucumber.core.gherkin.{Feature, Pickle}
+import io.cucumber.core.options.{
+  CucumberProperties,
+  CucumberPropertiesParser,
+  CucumberSuiteOptionsParser,
+  RuntimeOptions
+}
+import io.cucumber.core.plugin.{PluginFactory, Plugins}
+import io.cucumber.core.resource.ClassLoaders
+import io.cucumber.core.runtime.SynchronizedEventBus.synchronize
+import io.cucumber.core.runtime._
+import org.scalatest._
+
+import java.time.Clock
+import java.util.function.{Predicate, Supplier}
+import java.util.{Optional, UUID}
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+trait CucumberSuite extends Suite { this: CucumberSuiteOptions =>
+
+  private lazy val parsed = CucumberSuite.parse(this)
+
+  override def nestedSuites: IndexedSeq[Suite] = parsed.children.toIndexedSeq
+
+  override protected def runNestedSuites(args: Args): Status = {
+    println("runNestedSuites")
+    lazy val runChildren = {
+      println("runChildren")
+      super.runNestedSuites(args)
+    }
+    Try {
+      println("try")
+      parsed.context.runFeatures(() => {
+        println("runFeatures")
+        runChildren
+        ()
+      })
+      runChildren
+    }
+      .recover { case e: Throwable =>
+        println("recover")
+        e.printStackTrace()
+        FailedStatus
+      }
+      .getOrElse(FailedStatus)
+  }
+}
+
+object CucumberSuite {
+
+  private def parse[T <: CucumberSuiteOptions](
+      options: T
+  ): CucumberSuiteParsed = {
+
+    println(s"parse $options")
+
+    val propertiesFileOptions: RuntimeOptions =
+      new CucumberPropertiesParser()
+        .parse(CucumberProperties.fromPropertiesFile)
+        .build
+
+    println("tata")
+
+    val annotationOptions: RuntimeOptions =
+      CucumberSuiteOptionsParser
+        .unsafeParse(options)
+        .build(propertiesFileOptions)
+
+    println("titi")
+
+    val environmentOptions: RuntimeOptions =
+      new CucumberPropertiesParser()
+        .parse(CucumberProperties.fromEnvironment)
+        .build(annotationOptions)
+
+    val runtimeOptions: RuntimeOptions =
+      new CucumberPropertiesParser()
+        .parse(CucumberProperties.fromSystemProperties)
+        .enablePublishPlugin
+        .build(environmentOptions)
+
+    val bus: EventBus = synchronize(
+      new TimeServiceEventBus(Clock.systemUTC, () => UUID.randomUUID())
+    )
+
+    println("toto")
+
+    // Parse the features early. Don't proceed when there are lexer errors
+    val parser: FeatureParser = new FeatureParser(() => bus.generateId())
+    val classLoader: Supplier[ClassLoader] = () =>
+      ClassLoaders.getDefaultClassLoader
+    val featureSupplier: FeaturePathFeatureSupplier =
+      new FeaturePathFeatureSupplier(classLoader, runtimeOptions, parser)
+    val features: Seq[Feature] = featureSupplier.get.asScala.toSeq
+
+    // Create plugins after feature parsing to avoid the creation of empty
+    // files on lexer errors.
+    val plugins = new Plugins(new PluginFactory, runtimeOptions)
+    val exitStatus = new ExitStatus(runtimeOptions)
+    plugins.addPlugin(exitStatus)
+
+    val objectFactoryServiceLoader =
+      new ObjectFactoryServiceLoader(classLoader, runtimeOptions)
+    val objectFactorySupplier = new ThreadLocalObjectFactorySupplier(
+      objectFactoryServiceLoader
+    )
+    val backendSupplier = new BackendServiceLoader(
+      () => options.getClass.getClassLoader,
+      objectFactorySupplier
+    )
+    val runnerSupplier = new ThreadLocalRunnerSupplier(
+      runtimeOptions,
+      bus,
+      backendSupplier,
+      objectFactorySupplier
+    )
+    val context = new CucumberExecutionContext(bus, exitStatus, runnerSupplier)
+
+    val filters: Predicate[Pickle] = new Filters(runtimeOptions)
+
+    val groupedByName: Map[Optional[String], Seq[Feature]] =
+      features.groupBy(_.getName)
+    val children = features
+      .map { feature =>
+        val uniqueSuffix: Option[Int] = FilenameCompatibleNames
+          .uniqueSuffix(groupedByName, feature, (f: Feature) => f.getName)
+        FeatureSuite.createUnsafe(feature, uniqueSuffix, filters, context)
+      }
+      .filterNot(_.isEmpty)
+
+    println(children)
+
+    CucumberSuiteParsed(context, children)
+  }
+
+  private case class CucumberSuiteParsed(
+      context: CucumberExecutionContext,
+      children: Seq[FeatureSuite]
+  )
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala
new file mode 100644
index 00000000..c49b7c8c
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/CucumberSuiteOptions.scala
@@ -0,0 +1,131 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.backend.ObjectFactory
+import io.cucumber.core.eventbus.UuidGenerator
+import io.cucumber.scalatest.CucumberSuiteOptions.SnippetType
+
+trait CucumberSuiteOptions {
+
+  /** @return
+    *   true if glue code execution should be skipped.
+    */
+  def dryRun = false
+
+  /** A list of features paths. <p> A feature path is constructed as
+    * {@code [ PATH[.feature[:LINE]*] | URI[.feature[:LINE]*] | @PATH ]} <p>
+    * Examples: <ul> <li>{@code src/test/resources/features} -- All features in
+    * the {@code src/test/resources/features} directory</li> <li>{@code
+    * classpath:com/example/application} -- All features in the
+    * {@code com.example.application} package</li> <li>{@code
+    * in-memory:/features} -- All features in the {@code /features} directory on
+    * an in memory file system supported by {@link java.nio.file.FileSystems}
+    * </li> <li>{@code src/test/resources/features/example.feature:42} -- The
+    * scenario or example at line 42 in the example feature file</li> <li>{@code
+    * \@target/rerun} -- All the scenarios in the files in the rerun
+    * directory</li> <li>{@code @target/rerun/RunCucumber.txt} -- All the
+    * scenarios in RunCucumber.txt file</li> </ul> <p> When no feature path is
+    * provided, Cucumber will use the package of the annotated class. For
+    * example, if the annotated class is {@code com.example.RunCucumber} then
+    * features are assumed to be located in {@code classpath:com/example} .
+    *
+    * @return
+    *   list of files or directories
+    * @see
+    *   io.cucumber.core.feature.FeatureWithLines
+    */
+  def featuresPath: Seq[String] = Nil
+
+  /** Package to load glue code (step definitions, hooks and plugins) from. E.g:
+    * {@code com.example.app} <p> When no glue is provided, Cucumber will use
+    * the package of the annotated class. For example, if the annotated class is
+    * {@code com.example.RunCucumber} then glue is assumed to be located in
+    * {@code com.example} .
+    *
+    * @return
+    *   list of package names
+    * @see
+    *   io.cucumber.core.feature.GluePath
+    */
+  def gluePackages: Seq[String] = Nil
+
+  /** Package to load additional glue code (step definitions, hooks and plugins)
+    * from. E.g: {@code com.example.app} <p> These packages are used in addition
+    * to the default described in {@code #glue} .
+    *
+    * @return
+    *   list of package names
+    */
+  def extraGluePackages: Seq[String] = Nil
+
+  /** Only run scenarios tagged with tags matching <a
+    * href="https://github.com/cucumber/tag-expressions">Tag Expression</a>. <p>
+    * For example {@code "@smoke and not @fast"} .
+    *
+    * @return
+    *   a tag expression
+    */
+  def matchingTags = ""
+
+  /** Register plugins. Built-in plugin types: {@code junit} , {@code html} ,
+    * {@code pretty} , {@code progress} , {@code json} , {@code usage} ,
+    * {@code unused} , {@code rerun} , {@code testng} . <p> Can also be a fully
+    * qualified class name, allowing registration of 3rd party plugins. <p>
+    * Plugins can be provided with an argument. For example
+    * {@code json:target/cucumber-report.json}
+    *
+    * @return
+    *   list of plugins
+    * @see
+    *   Plugin
+    */
+  def plugins: Seq[String] = Nil
+
+  /** Publish report to https://reports.cucumber.io. <p>
+    *
+    * @return
+    *   true if reports should be published on the web.
+    */
+  def publish = false
+
+  /** @return
+    *   true if terminal output should be without colours.
+    */
+  def monochrome: Boolean = false
+
+  /** Only run scenarios whose names match one of the provided regular
+    * expressions.
+    *
+    * @return
+    *   a list of regular expressions
+    */
+  def names: Seq[String] = Nil
+
+  /** @return
+    *   the format of the generated snippets.
+    */
+  def snippets: SnippetType = SnippetType.UNDERSCORE
+
+  /** Specify a custom ObjectFactory. <p> In case a custom ObjectFactory is
+    * needed, the class can be specified here. A custom ObjectFactory might be
+    * needed when more granular control is needed over the dependency injection
+    * mechanism.
+    *
+    * @return
+    *   an {@link io.cucumber.core.backend.ObjectFactory} implementation
+    */
+  def objectFactory: Class[_ <: ObjectFactory] = classOf[NoObjectFactory]
+
+  def uuidGenerator: Class[_ <: UuidGenerator] = classOf[NoUuidGenerator]
+
+}
+
+object CucumberSuiteOptions {
+
+  sealed trait SnippetType
+
+  object SnippetType {
+    case object UNDERSCORE extends SnippetType
+    case object CAMELCASE extends SnippetType
+  }
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala
new file mode 100644
index 00000000..1cf6ea8a
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/FeatureSuite.scala
@@ -0,0 +1,79 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.exception.CucumberException
+import io.cucumber.core.gherkin.{Feature, Pickle}
+import io.cucumber.core.runtime.CucumberExecutionContext
+import org.scalatest.{Args, Status, Suite}
+
+import java.util.function.Predicate
+import scala.jdk.CollectionConverters.CollectionHasAsScala
+import scala.jdk.OptionConverters.RichOptional
+import scala.util.Try
+
+private[scalatest] final class FeatureSuite(
+    private val feature: Feature,
+    private val uniqueSuffix: Option[Int],
+    private val filter: Predicate[Pickle],
+    private val context: CucumberExecutionContext
+) extends Suite {
+
+  private val children: Seq[PickleSuite] = {
+    val groupedByName: Map[String, Seq[Pickle]] =
+      feature.getPickles.asScala.toSeq.groupBy(_.getName)
+    feature.getPickles.asScala.toSeq
+      .filter(p => filter.test(p))
+      .map { (pickle: Pickle) =>
+        val featureName = suiteName
+        val exampleId = FilenameCompatibleNames.uniqueSuffix(
+          groupedByName,
+          pickle,
+          (p: Pickle) => p.getName
+        )
+        PickleSuite.withNoStepDescriptions(
+          featureName,
+          context,
+          pickle,
+          exampleId
+        )
+      }
+  }
+
+  override def suiteName: String = {
+    val name = feature.getName.toScala.getOrElse("EMPTY_NAME")
+    FilenameCompatibleNames.createName(name, uniqueSuffix, false)
+  }
+
+  def isEmpty: Boolean = children.isEmpty
+
+  override protected def runNestedSuites(args: Args): Status = {
+    context.beforeFeature(feature)
+    super.runNestedSuites(args)
+  }
+
+  override def nestedSuites: IndexedSeq[Suite] = children.toIndexedSeq
+
+}
+
+object FeatureSuite {
+
+  def createUnsafe(
+      feature: Feature,
+      uniqueSuffix: Option[Int],
+      filter: Predicate[Pickle],
+      context: CucumberExecutionContext
+  ): FeatureSuite = create(feature, uniqueSuffix, filter, context).get
+
+  def create(
+      feature: Feature,
+      uniqueSuffix: Option[Int],
+      filter: Predicate[Pickle],
+      context: CucumberExecutionContext
+  ): Try[FeatureSuite] = {
+    Try {
+      new FeatureSuite(feature, uniqueSuffix, filter, context)
+    }.recover { case e: Throwable =>
+      throw new CucumberException("Failed to create scenario runner", e)
+    }
+  }
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala b/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala
new file mode 100644
index 00000000..7dd22ce7
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/FilenameCompatibleNames.scala
@@ -0,0 +1,44 @@
+package io.cucumber.scalatest
+
+object FilenameCompatibleNames {
+
+  private[scalatest] def createName(
+      name: String,
+      uniqueSuffix: Option[Int],
+      useFilenameCompatibleNames: Boolean
+  ): String = {
+    uniqueSuffix match {
+      case Some(suffix) =>
+        createName(name + " #" + suffix + "", useFilenameCompatibleNames)
+      case None => createName(name, useFilenameCompatibleNames)
+    }
+  }
+
+  private[scalatest] def createName(
+      name: String,
+      useFilenameCompatibleNames: Boolean
+  ): String = {
+    if (useFilenameCompatibleNames) {
+      makeNameFilenameCompatible(name)
+    } else {
+      name
+    }
+  }
+
+  private def makeNameFilenameCompatible(name: String): String = {
+    name.replaceAll("[^A-Za-z0-9_]", "_")
+  }
+
+  private[scalatest] def uniqueSuffix[V, K](
+      groupedByName: Map[K, Seq[V]],
+      pickle: V,
+      nameOf: V => K
+  ): Option[Int] = {
+    val withSameName = groupedByName.get(nameOf.apply(pickle))
+    withSameName match {
+      case Some(x) if x.size > 1 => Some(x.indexOf(pickle) + 1)
+      case _                     => None
+    }
+  }
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala b/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala
new file mode 100644
index 00000000..ec469faa
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/NoObjectFactory.scala
@@ -0,0 +1,17 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.backend.ObjectFactory
+
+/** This object factory does nothing. It is solely needed for marking purposes.
+  */
+final class NoObjectFactory extends ObjectFactory {
+
+  override def addClass(glueClass: Class[_]): Boolean = false
+
+  override def getInstance[T](glueClass: Class[T]): T = null.asInstanceOf[T]
+
+  override def start(): Unit = {}
+
+  override def stop(): Unit = {}
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala b/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala
new file mode 100644
index 00000000..1fa68968
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/NoUuidGenerator.scala
@@ -0,0 +1,13 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.eventbus.UuidGenerator
+
+import java.util.UUID
+
+/** This UUID generator does nothing. It is solely needed for marking purposes.
+  */
+final class NoUuidGenerator extends UuidGenerator {
+
+  override def generateId: UUID = null
+
+}
diff --git a/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala b/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala
new file mode 100644
index 00000000..0fe6ee7b
--- /dev/null
+++ b/scalatest/src/main/scala/io/cucumber/scalatest/PickleSuite.scala
@@ -0,0 +1,47 @@
+package io.cucumber.scalatest
+
+import io.cucumber.core.gherkin.Pickle
+import io.cucumber.core.runner.Runner
+import io.cucumber.core.runtime.CucumberExecutionContext
+import org.scalatest._
+
+import scala.util.Try
+
+private[scalatest] final class PickleSuite(
+    private val featureName: String,
+    private val context: CucumberExecutionContext,
+    private val pickle: Pickle,
+    private val uniqueSuffix: Option[Int]
+) extends Suite {
+
+  private val testName =
+    FilenameCompatibleNames.createName(pickle.getName, uniqueSuffix, false)
+
+  override def suiteName: String = {
+    val className = FilenameCompatibleNames.createName(featureName, false)
+    s"$className $testName"
+  }
+
+  override def testNames: Set[String] = Set(testName)
+
+  override def runTest(testName: String, args: Args): Status = {
+    Try {
+      context.runTestCase { (runner: Runner) =>
+        runner.runPickle(pickle)
+      }
+      SucceededStatus
+    }.getOrElse(FailedStatus)
+  }
+
+}
+
+object PickleSuite {
+
+  def withNoStepDescriptions(
+      featureName: String,
+      context: CucumberExecutionContext,
+      pickle: Pickle,
+      uniqueSuffix: Option[Int]
+  ): PickleSuite = new PickleSuite(featureName, context, pickle, uniqueSuffix)
+
+}