Skip to content

Add Support for Cucumber using @split[feature:treatment] tags #247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions testing/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,15 @@
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>6.10.4</version>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be test scope, no?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably

Copy link
Author

@aslakhellesoy aslakhellesoy Jul 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've thought a bit more about this. I think there are two options here - compile scope or provided scope. (See maven docs for details).

The junit dependency is already compile scope, so using the same scope for cucumber-java and cucumber-junit would be consistent with that.

Alternatively, making it provided scope would indicate that dependent modules would have to explicitly add the dependencies themselves. I think that's ok too, if you don't want consumers of the library transitively download cucumber unless they opt into it with an explicit dependency in their own pom/gradle file.

I'll defer to you to decide what's most appropriate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone is already using this framework (our Java-testing) without cucumber and I merge this PR. Regardless of the provided or not, I believe the build will fail for not finding the dependency in the classpath, right?

I need time to sync internally but if that's the case we may need to split this into a separate submodule.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the build will fail for not finding the dependency in the classpath, right?

No it won't.

In Java, classes are only loaded when they are referenced by another class. Classes are not loaded by mere presence in a jar file.

No other classes in this library reference io.split.client.testing.cucumber.CucumberSplit, so it won't be loaded.

</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit</artifactId>
<version>6.10.4</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.split.client.testing.cucumber;

import io.cucumber.java.Scenario;
import io.split.client.testing.SplitClientForTest;

import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* <p>
* Simple Cucumber plugin for Split.
* </p>
* <p>
* Cucumber scenarios annotated with {@code @split[feature:treatment]} tags can be used to
* configure a {@link SplitClientForTest} instance.
* </p>
* <p>
* To use it, define a <a href="https://cucumber.io/docs/cucumber/api/#hooks">Before Hook</a> that invokes the {@link CucumberSplit#configureSplit(SplitClientForTest, Scenario)}
* method. Example:
* </p>
*
* <pre>
* import io.cucumber.java.Before;
* import io.split.client.testing.SplitClientForTest;
*
* public class StepDefinitions {
* private final SplitClientForTest splitClient = new SplitClientForTest();
*
* &#64;Before
* public void configureSplit(Scenario scenario) {
* CucumberSplit.configureSplit(splitClient, scenario);
* }
* }
* </pre>
*/
public class CucumberSplit {
private static final Pattern SPLIT_TAG_PATTERN = Pattern.compile("^@split\\[(.*):(.*)]");

public static void configureSplit(SplitClientForTest splitClient, Scenario scenario) {
Collection<String> tags = scenario.getSourceTagNames();
for (String tag : tags) {
Matcher matcher = SPLIT_TAG_PATTERN.matcher(tag);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question here:

  • if more than 1 tag matches this, do you end up overwriting the registered (feature, treatment) pair?

Copy link
Author

@aslakhellesoy aslakhellesoy Jul 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This is intentional.

Tags that are placed at the top level Feature level are inherited by all the Scenarios. The getSourceTagNames() method returns a Collection of the tags from both the Feature and the current Scenario. The inherited Feature tags are always before the Scenario tags in the collection (it would have been more appropriate to declare the return type as List rather than Collection since it's ordered).

We're taking advantage of this here to allow users to define "default" treatments at the feature level, and (optionally) override them at the scenario level. I have illustrated this in the last scenario where the default dollars=off treatment from the feature level is overridden to dollars=on for that particular scenario.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed some more changes to (hopefully) illustrate better how this works.

if (matcher.matches()) {
String feature = matcher.group(1);
String treatment = matcher.group(2);
splitClient.registerTreatment(feature, treatment);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.split.client.testing.cucumber;

import io.split.client.SplitClient;

import java.util.ArrayList;
import java.util.List;

import static java.util.Collections.emptyList;
import static java.util.Collections.unmodifiableList;

/**
* A simple coffee machine that displays available drinks. It can offer an experimental cappuccino
* drink that is toggled on/off with Split.
*/
public class CoffeeMachine {
private final SplitClient splitClient;
private final String splitKey;
private double level;

public CoffeeMachine(SplitClient splitClient, String splitKey) {
this.splitClient = splitClient;
this.splitKey = splitKey;
}

/**
* Indicate how full the machine is
*
* @param level a number between 0 and 1
*/
public void setLevel(double level) {
this.level = level;
}

public List<SKU> getAvailableDrinks() {
if(this.level == 0) return emptyList();

List<SKU> availableDrinks = new ArrayList<>();
availableDrinks.add(new SKU("filter coffee", 0.80));
if ("on".equals(this.splitClient.getTreatment(splitKey, "cappuccino"))) {
availableDrinks.add(new SKU("cappuccino", 1.10));
}
return unmodifiableList(availableDrinks);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.split.client.testing.cucumber;

import io.cucumber.junit.Cucumber;
import org.junit.runner.RunWith;

// This is the entry point for Cucumber, which runs all the .feature files in the same package
@RunWith(Cucumber.class)
public class RunCucumberTest {
}
37 changes: 37 additions & 0 deletions testing/src/test/java/io/split/client/testing/cucumber/SKU.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.split.client.testing.cucumber;

import java.util.Objects;

/**
* A simple <a href="https://en.wikipedia.org/wiki/Stock_keeping_unit">Stock Keeping Unit</a> (SKU).
*/
public class SKU {
private final String name;
private final double price;

public SKU(String name, double price) {
this.name = name;
this.price = price;
}

@Override
public String toString() {
return "SKU{" +
"name='" + name + '\'' +
", price=" + price +
'}';
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SKU sku = (SKU) o;
return Double.compare(sku.price, price) == 0 && name.equals(sku.name);
}

@Override
public int hashCode() {
return Objects.hash(name, price);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.split.client.testing.cucumber;

import io.cucumber.java.Before;
import io.cucumber.java.DataTableType;
import io.cucumber.java.Scenario;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.split.client.testing.SplitClientForTest;

import java.util.List;
import java.util.Map;

import static java.util.Collections.emptyList;
import static org.junit.Assert.assertEquals;

public class StepDefinitions {
private final SplitClientForTest splitClient = new SplitClientForTest();
private final CoffeeMachine coffeeMachine = new CoffeeMachine(splitClient, "arbitraryKey");

// Called by Cucumber to convert each row in the data table in the .feature file to a SKU object
@DataTableType
public SKU sku(Map<String, String> entry) {
return new SKU(
entry.get("name"),
Double.parseDouble(entry.get("price"))
);
}

@Given("the machine is not empty")
public void the_machine_is_not_empty() {
coffeeMachine.setLevel(1.0);
}

@Given("the machine is empty")
public void the_machine_is_empty() {
coffeeMachine.setLevel(0);
}

@Then("the following drinks should be available:")
public void the_following_drinks_should_be_available(List<SKU> expectedSKUs) {
List<SKU> availableSKUs = coffeeMachine.getAvailableDrinks();
assertEquals(expectedSKUs, availableSKUs);
}

@Then("no drinks should be available")
public void no_drinks_should_be_available() {
List<SKU> availableSKUs = coffeeMachine.getAvailableDrinks();
assertEquals(emptyList(), availableSKUs);
}

@Before
public void configureSplit(Scenario scenario) {
CucumberSplit.configureSplit(splitClient, scenario);
}
}
1 change: 1 addition & 0 deletions testing/src/test/resources/cucumber.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cucumber.publish.quiet=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# This tag is inherited by all the scenarios, setting the "cappuccino" split feature to "off" by default.
@split[cappuccino:off]
Feature: Make Coffee
The scenarios in this feature file describes how the coffee machine works.

Scenario: Empty machine
Given the machine is empty
Then no drinks should be available

Scenario: Display available drinks
Given the machine is not empty
Then the following drinks should be available:
| name | price |
| filter coffee | 0.80 |

# The tags on this scenario will be ["@split[cappuccino:off]", "@split[cappuccino:on]"]
# The @split tags are processed sequentially, so the cappuccino split feature will be set to "off"
# and then immediately overwritten to "on".
@split[cappuccino:on]
Scenario: Display available drinks (including the new experimental cappuccino)
Given the machine is not empty
Then the following drinks should be available:
| name | price |
| filter coffee | 0.80 |
| cappuccino | 1.10 |