Skip to content

Commit

Permalink
[JUnit Platform] Enable parallel execution of features (#2604)
Browse files Browse the repository at this point in the history
Enables the JUnit 4 behaviour where all scenarios in a feature are executed on the same thread

Co-authored-by: M.P. Korstanje <rien.korstanje@gmail.com>
  • Loading branch information
sambathkumar-sekar and mpkorstanje authored Sep 8, 2022
1 parent c22dce5 commit 627ffa1
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 162 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- [JUnit Platform] Enable parallel execution of features ([#2604](https://github.com/cucumber/cucumber-jvm/pull/2604) Sambathkumar Sekar)

## [7.6.0] - 2022-08-08
### Changed
Expand Down
13 changes: 13 additions & 0 deletions cucumber-junit-platform-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ with this configuration:
```properties
cucumber.execution.exclusive-resources.isolated.read-write=org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_KEY
```
### Executing features in parallel

By default, when parallel execution in enabled, scenarios and examples are
executed in parallel. Due to limitations JUnit 4 could only execute features in
parallel. This behaviour can be restored by setting the configuration parameter
`cucumber.execution.execution-mode.feature` to `same_thread`.

## Configuration Options ##

Expand Down Expand Up @@ -330,6 +336,13 @@ cucumber.snippet-type= # underscore or ca
cucumber.execution.dry-run= # true or false.
# default: false
cucumber.execution.execution-mode.feature= # same_thread or concurrent
# default: concurrent
# same_thread - executes scenarios sequentially in the
# same thread as the parent feature
# conncurrent - executes scenarios concurrently on any
# available thread
cucumber.execution.parallel.enabled= # true or false.
# default: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ public final class Constants {
*/
public static final String SNIPPET_TYPE_PROPERTY_NAME = io.cucumber.core.options.Constants.SNIPPET_TYPE_PROPERTY_NAME;

/**
* Property name used to set the executing thread for all scenarios and
* examples in a feature: {@value}
* <p>
* Valid values are {@code same_thread} or {@code concurrent}. Default value
* is {@code concurrent}.
* <p>
* When parallel execution is enabled, scenarios are executed in parallel on
* any available thread. setting this property to {@code same_thread}
* executes scenarios sequentially in the same thread as the parent feature.
*
* @see #PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME
*/
public static final String EXECUTION_MODE_FEATURE_PROPERTY_NAME = "cucumber.execution.execution-mode.feature";

/**
* Property name used to enable parallel test execution: {@value}
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.cucumber.core.feature.FeatureWithLines;
import io.cucumber.core.logging.Logger;
import io.cucumber.core.logging.LoggerFactory;
import io.cucumber.junit.platform.engine.NodeDescriptor.PickleDescriptor;
import org.junit.platform.engine.ConfigurationParameters;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.Filter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import io.cucumber.core.logging.LoggerFactory;
import io.cucumber.core.resource.ClassLoaders;
import io.cucumber.core.resource.ResourceScanner;
import io.cucumber.junit.platform.engine.NodeDescriptor.ExamplesDescriptor;
import io.cucumber.junit.platform.engine.NodeDescriptor.PickleDescriptor;
import io.cucumber.junit.platform.engine.NodeDescriptor.RuleDescriptor;
import io.cucumber.junit.platform.engine.NodeDescriptor.ScenarioOutlineDescriptor;
import io.cucumber.plugin.event.Node;
import org.junit.platform.engine.ConfigurationParameters;
import org.junit.platform.engine.TestDescriptor;
Expand Down Expand Up @@ -85,7 +89,8 @@ private FeatureDescriptor createFeatureDescriptor(Feature feature) {
source.featureSource(),
feature),
(Node.Rule node, TestDescriptor parent) -> {
TestDescriptor descriptor = new NodeDescriptor(
TestDescriptor descriptor = new RuleDescriptor(
parameters,
source.ruleSegment(parent.getUniqueId(), node),
namingStrategy.name(node),
source.nodeSource(node));
Expand All @@ -103,15 +108,17 @@ private FeatureDescriptor createFeatureDescriptor(Feature feature) {
return descriptor;
},
(Node.ScenarioOutline node, TestDescriptor parent) -> {
TestDescriptor descriptor = new NodeDescriptor(
TestDescriptor descriptor = new ScenarioOutlineDescriptor(
parameters,
source.scenarioSegment(parent.getUniqueId(), node),
namingStrategy.name(node),
source.nodeSource(node));
parent.addChild(descriptor);
return descriptor;
},
(Node.Examples node, TestDescriptor parent) -> {
NodeDescriptor descriptor = new NodeDescriptor(
NodeDescriptor descriptor = new ExamplesDescriptor(
parameters,
source.examplesSegment(parent.getUniqueId(), node),
namingStrategy.name(node),
source.nodeSource(node));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,221 @@
package io.cucumber.junit.platform.engine;

import io.cucumber.core.gherkin.Pickle;
import io.cucumber.core.resource.ClasspathSupport;
import org.junit.platform.engine.ConfigurationParameters;
import org.junit.platform.engine.TestSource;
import org.junit.platform.engine.TestTag;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.support.config.PrefixedConfigurationParameters;
import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
import org.junit.platform.engine.support.descriptor.ClasspathResourceSource;
import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode;
import org.junit.platform.engine.support.hierarchical.Node;

class NodeDescriptor extends AbstractTestDescriptor {
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;

NodeDescriptor(UniqueId uniqueId, String name, TestSource source) {
import static io.cucumber.junit.platform.engine.Constants.EXECUTION_EXCLUSIVE_RESOURCES_PREFIX;
import static io.cucumber.junit.platform.engine.Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME;
import static io.cucumber.junit.platform.engine.Constants.READ_SUFFIX;
import static io.cucumber.junit.platform.engine.Constants.READ_WRITE_SUFFIX;
import static java.util.stream.Collectors.collectingAndThen;
import static java.util.stream.Collectors.toCollection;

abstract class NodeDescriptor extends AbstractTestDescriptor implements Node<CucumberEngineExecutionContext> {

private final ExecutionMode executionMode;

NodeDescriptor(ConfigurationParameters parameters, UniqueId uniqueId, String name, TestSource source) {
super(uniqueId, name, source);
this.executionMode = parameters
.get(EXECUTION_MODE_FEATURE_PROPERTY_NAME,
value -> ExecutionMode.valueOf(value.toUpperCase(Locale.US)))
.orElse(ExecutionMode.CONCURRENT);
}

@Override
public Type getType() {
return Type.CONTAINER;
public ExecutionMode getExecutionMode() {
return executionMode;
}

static final class ExamplesDescriptor extends NodeDescriptor {

ExamplesDescriptor(ConfigurationParameters parameters, UniqueId uniqueId, String name, TestSource source) {
super(parameters, uniqueId, name, source);
}

@Override
public Type getType() {
return Type.CONTAINER;
}

}

static final class RuleDescriptor extends NodeDescriptor {

RuleDescriptor(ConfigurationParameters parameters, UniqueId uniqueId, String name, TestSource source) {
super(parameters, uniqueId, name, source);
}

@Override
public Type getType() {
return Type.CONTAINER;
}

}

static final class ScenarioOutlineDescriptor extends NodeDescriptor {

ScenarioOutlineDescriptor(
ConfigurationParameters parameters, UniqueId uniqueId, String name,
TestSource source
) {
super(parameters, uniqueId, name, source);
}

@Override
public Type getType() {
return Type.CONTAINER;
}

}

static final class PickleDescriptor extends NodeDescriptor {

private final Pickle pickleEvent;
private final Set<TestTag> tags;
private final Set<ExclusiveResource> exclusiveResources = new LinkedHashSet<>(0);

PickleDescriptor(
ConfigurationParameters parameters, UniqueId uniqueId, String name, TestSource source,
Pickle pickleEvent
) {
super(parameters, uniqueId, name, source);
this.pickleEvent = pickleEvent;
this.tags = getTags(pickleEvent);
this.tags.forEach(tag -> {
ExclusiveResourceOptions exclusiveResourceOptions = new ExclusiveResourceOptions(parameters, tag);
exclusiveResourceOptions.exclusiveReadWriteResource()
.map(resource -> new ExclusiveResource(resource, LockMode.READ_WRITE))
.forEach(exclusiveResources::add);
exclusiveResourceOptions.exclusiveReadResource()
.map(resource -> new ExclusiveResource(resource, LockMode.READ))
.forEach(exclusiveResources::add);
});
}

private Set<TestTag> getTags(Pickle pickleEvent) {
return pickleEvent.getTags().stream()
.map(tag -> tag.substring(1))
.filter(TestTag::isValid)
.map(TestTag::create)
// Retain input order
.collect(collectingAndThen(toCollection(LinkedHashSet::new), Collections::unmodifiableSet));
}

@Override
public Type getType() {
return Type.TEST;
}

@Override
public SkipResult shouldBeSkipped(CucumberEngineExecutionContext context) {
return Stream.of(shouldBeSkippedByTagFilter(context), shouldBeSkippedByNameFilter(context))
.flatMap(skipResult -> skipResult.map(Stream::of).orElseGet(Stream::empty))
.filter(SkipResult::isSkipped)
.findFirst()
.orElseGet(SkipResult::doNotSkip);
}

private Optional<SkipResult> shouldBeSkippedByTagFilter(CucumberEngineExecutionContext context) {
return context.getOptions().tagFilter().map(expression -> {
if (expression.evaluate(pickleEvent.getTags())) {
return SkipResult.doNotSkip();
}
return SkipResult
.skip(
"'" + Constants.FILTER_TAGS_PROPERTY_NAME + "=" + expression
+ "' did not match this scenario");
});
}

private Optional<SkipResult> shouldBeSkippedByNameFilter(CucumberEngineExecutionContext context) {
return context.getOptions().nameFilter().map(pattern -> {
if (pattern.matcher(pickleEvent.getName()).matches()) {
return SkipResult.doNotSkip();
}
return SkipResult
.skip("'" + Constants.FILTER_NAME_PROPERTY_NAME + "=" + pattern
+ "' did not match this scenario");
});
}

@Override
public CucumberEngineExecutionContext execute(
CucumberEngineExecutionContext context, DynamicTestExecutor dynamicTestExecutor
) {
context.runTestCase(pickleEvent);
return context;
}

@Override
public Set<ExclusiveResource> getExclusiveResources() {
return exclusiveResources;
}

/**
* Returns the set of {@linkplain TestTag tags} for a pickle.
* <p>
* Note that Cucumber will remove the {code @} symbol from all Gherkin
* tags. So a scenario tagged with {@code @Smoke} becomes a test tagged
* with {@code Smoke}.
*
* @return the set of tags
*/
@Override
public Set<TestTag> getTags() {
return tags;
}

Optional<String> getPackage() {
return getSource()
.filter(ClasspathResourceSource.class::isInstance)
.map(ClasspathResourceSource.class::cast)
.map(ClasspathResourceSource::getClasspathResourceName)
.map(ClasspathSupport::packageNameOfResource);
}

private static final class ExclusiveResourceOptions {

private final ConfigurationParameters parameters;

ExclusiveResourceOptions(ConfigurationParameters parameters, TestTag tag) {
this.parameters = new PrefixedConfigurationParameters(
parameters,
EXECUTION_EXCLUSIVE_RESOURCES_PREFIX + tag.getName());
}

public Stream<String> exclusiveReadWriteResource() {
return parameters.get(READ_WRITE_SUFFIX, s -> Arrays.stream(s.split(","))
.map(String::trim))
.orElse(Stream.empty());
}

public Stream<String> exclusiveReadResource() {
return parameters.get(READ_SUFFIX, s -> Arrays.stream(s.split(","))
.map(String::trim))
.orElse(Stream.empty());
}

}

}

}
Loading

0 comments on commit 627ffa1

Please sign in to comment.