Repository: open-feature/java-sdk
Pull request #1363 (2025-03-06 08:17:37)

feat: implement gherkin tests
## This PR
Implements the [recently added](https://github.com/open-feature/spec/pull/293) gherkin tests 

### Related Issues

Part of https://github.com/open-feature/flagd-testbed/issues/13


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) Report
Attention: Patch coverage is `0%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 93.16%. Comparing base [(`67b34f8`)](https://app.codecov.io/gh/open-feature/java-sdk/commit/67b34f84a373512013ab2f7649faaddfd2d61048?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) to head [(`59b95fe`)](https://app.codecov.io/gh/open-feature/java-sdk/commit/59b95fe161379bf253ebb30eb45e6be8b1ad23ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature).

| [Files with missing lines](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) | Patch % | Lines |
|---|---|---|
| [...in/java/dev/openfeature/sdk/ImmutableMetadata.java](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363?src=pr&el=tree&filepath=src%2Fmain%2Fjava%2Fdev%2Fopenfeature%2Fsdk%2FImmutableMetadata.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature#diff-c3JjL21haW4vamF2YS9kZXYvb3BlbmZlYXR1cmUvc2RrL0ltbXV0YWJsZU1ldGFkYXRhLmphdmE=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##               main    #1363      +/-   ##
============================================
+ Coverage     92.80%   93.16%   +0.36%     
- Complexity      466      468       +2     
============================================
  Files            43       43              
  Lines          1125     1126       +1     
  Branches         90       90              
============================================
+ Hits           1044     1049       +5     
+ Misses           53       49       -4     
  Partials         28       28              
```

| [Flag](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) | `93.16% <0.00%> (+0.36%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/open-feature/java-sdk/pull/1363?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=open-feature_java-sdk&pullRequest=1363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=open-feature_java-sdk&pullRequest=1363&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=open-feature_java-sdk&pullRequest=1363)



Patch:
diff --git a/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
index f8311a9..7f57a17 100644
--- a/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
+++ b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java
@@ -101,6 +101,10 @@ public class ImmutableMetadata {
         return metadata.isEmpty();
     }
 
+    public boolean isNotEmpty() {
+        return !metadata.isEmpty();
+    }
+
     /**
      * Obtain a builder for {@link ImmutableMetadata}.
      */


Test patch:
diff --git a/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
index 26d0421..2291266 100644
--- a/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
+++ b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java
@@ -64,23 +64,25 @@ class FlagMetadataTest {
     }
 
     @Test
-    @DisplayName("isEmpty returns true iff the metadata is empty")
-    void isEmpty_returns_true_if_metadata_is_empty() {
+    @DisplayName("isEmpty and isNotEmpty return correctly when the metadata is empty")
+    void isEmpty_isNotEmpty_return_correctly_when_metadata_is_empty() {
         // given
         ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build();
 
         // then
         assertTrue(flagMetadata.isEmpty());
+        assertFalse(flagMetadata.isNotEmpty());
     }
 
     @Test
-    @DisplayName("isEmpty returns false iff the metadata is not empty")
-    void isEmpty_returns_false_if_metadata_is_not_empty() {
+    @DisplayName("isEmpty and isNotEmpty return correctly when the metadata is not empty")
+    void isEmpty_isNotEmpty_return_correctly_when_metadata_is_not_empty() {
         // given
         ImmutableMetadata flagMetadata =
                 ImmutableMetadata.builder().addString("a", "b").build();
 
         // then
         assertFalse(flagMetadata.isEmpty());
+        assertTrue(flagMetadata.isNotEmpty());
     }
 }
diff --git a/src/test/java/dev/openfeature/sdk/e2e/ContextStoringProvider.java b/src/test/java/dev/openfeature/sdk/e2e/ContextStoringProvider.java
new file mode 100644
index 0000000..e06e862
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/ContextStoringProvider.java
@@ -0,0 +1,48 @@
+package dev.openfeature.sdk.e2e;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import lombok.Getter;
+
+@Getter
+public class ContextStoringProvider implements FeatureProvider {
+    private EvaluationContext evaluationContext;
+
+    @Override
+    public Metadata getMetadata() {
+        return () -> getClass().getSimpleName();
+    }
+
+    @Override
+    public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
+        this.evaluationContext = ctx;
+        return null;
+    }
+
+    @Override
+    public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
+        this.evaluationContext = ctx;
+        return null;
+    }
+
+    @Override
+    public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
+        this.evaluationContext = ctx;
+        return null;
+    }
+
+    @Override
+    public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
+        this.evaluationContext = ctx;
+        return null;
+    }
+
+    @Override
+    public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
+        this.evaluationContext = ctx;
+        return null;
+    }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/State.java b/src/test/java/dev/openfeature/sdk/e2e/State.java
index ee513b0..68c708b 100644
--- a/src/test/java/dev/openfeature/sdk/e2e/State.java
+++ b/src/test/java/dev/openfeature/sdk/e2e/State.java
@@ -1,8 +1,11 @@
 package dev.openfeature.sdk.e2e;
 
 import dev.openfeature.sdk.Client;
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
 import dev.openfeature.sdk.FlagEvaluationDetails;
 import dev.openfeature.sdk.MutableContext;
+import java.util.List;
 
 public class State {
     public Client client;
@@ -10,4 +13,7 @@ public class State {
     public MutableContext context = new MutableContext();
     public FlagEvaluationDetails evaluation;
     public MockHook hook;
+    public FeatureProvider provider;
+    public EvaluationContext invocationContext;
+    public List<String> levels;
 }
diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java
new file mode 100644
index 0000000..ccb78e7
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/e2e/steps/ContextSteps.java
@@ -0,0 +1,104 @@
+package dev.openfeature.sdk.e2e.steps;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.Hook;
+import dev.openfeature.sdk.HookContext;
+import dev.openfeature.sdk.ImmutableContext;
+import dev.openfeature.sdk.OpenFeatureAPI;
+import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.e2e.ContextStoringProvider;
+import dev.openfeature.sdk.e2e.State;
+import io.cucumber.datatable.DataTable;
+import io.cucumber.java.en.And;
+import io.cucumber.java.en.Given;
+import io.cucumber.java.en.Then;
+import io.cucumber.java.en.When;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public class ContextSteps {
+    private final State state;
+
+    public ContextSteps(State state) {
+        this.state = state;
+    }
+
+    @Given("a stable provider with retrievable context is registered")
+    public void setup() {
+        ContextStoringProvider provider = new ContextStoringProvider();
+        state.provider = provider;
+        OpenFeatureAPI.getInstance().setProviderAndWait(provider);
+        state.client = OpenFeatureAPI.getInstance().getClient();
+        OpenFeatureAPI.getInstance().setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator());
+    }
+
+    @When("A context entry with key {string} and value {string} is added to the {string} level")
+    public void aContextWithKeyAndValueIsAddedToTheLevel(String contextKey, String contextValue, String level) {
+        addContextEntry(contextKey, contextValue, level);
+    }
+
+    private void addContextEntry(String contextKey, String contextValue, String level) {
+        Map<String, Value> data = new HashMap<>();
+        data.put(contextKey, new Value(contextValue));
+        EvaluationContext context = new ImmutableContext(data);
+        if ("API".equals(level)) {
+            OpenFeatureAPI.getInstance().setEvaluationContext(context);
+        } else if ("Transaction".equals(level)) {
+            OpenFeatureAPI.getInstance().setTransactionContext(context);
+        } else if ("Client".equals(level)) {
+            state.client.setEvaluationContext(context);
+        } else if ("Invocation".equals(level)) {
+            state.invocationContext = context;
+        } else if ("Before Hooks".equals(level)) {
+            state.client.addHooks(new Hook() {
+                @Override
+                public Optional<EvaluationContext> before(HookContext ctx, Map hints) {
+                    return Optional.of(context);
+                }
+            });
+        } else {
+            throw new IllegalArgumentException("Unknown level: " + level);
+        }
+    }
+
+    @When("Some flag was evaluated")
+    public void someFlagWasEvaluated() {
+        state.evaluation = state.client.getStringDetails("unused", "unused", state.invocationContext);
+    }
+
+    @Then("The merged context contains an entry with key {string} and value {string}")
+    public void theMergedContextContainsAnEntryWithKeyAndValue(String contextKey, String contextValue) {
+        assertInstanceOf(
+                ContextStoringProvider.class,
+                state.provider,
+                "In order to use this step, you need to set a ContextStoringProvider");
+        EvaluationContext ctx = ((ContextStoringProvider) state.provider).getEvaluationContext();
+        assertNotNull(ctx);
+        assertNotNull(ctx.getValue(contextKey));
+        assertNotNull(ctx.getValue(contextKey).asString());
+        assertEquals(contextValue, ctx.getValue(contextKey).asString());
+    }
+
+    @Given("A table with levels of increasing precedence")
+    public void aTableWithLevelsOfIncreasingPrecedence(DataTable levelsTable) {
+        state.levels = levelsTable.asList();
+    }
+
+    @And(
+            "Context entries for each level from API level down to the {string} level, with key {string} and value {string}")
+    public void contextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndValue(
+            String maxLevel, String key, String value) {
+        for (String level : state.levels) {
+            addContextEntry(key, value, level);
+            if (level.equals(maxLevel)) {
+                return;
+            }
+        }
+    }
+}
diff --git a/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java
index 6897e27..924c9d5 100644
--- a/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java
+++ b/src/test/java/dev/openfeature/sdk/e2e/steps/StepDefinitions.java
@@ -2,7 +2,6 @@ package dev.openfeature.sdk.e2e.steps;
 
 import static dev.openfeature.sdk.testutils.TestFlagsUtils.buildFlags;
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import dev.openfeature.sdk.Client;
 import dev.openfeature.sdk.EvaluationContext;
@@ -289,7 +288,7 @@ public class StepDefinitions {
     @Then("the reason should indicate an error and the error code should indicate a missing flag with {string}")
     public void the_reason_should_indicate_an_error_and_the_error_code_should_be_flag_not_found(String errorCode) {
         assertEquals(Reason.ERROR.toString(), notFoundDetails.getReason());
-        assertTrue(notFoundDetails.getErrorCode().name().equals(errorCode));
+        assertEquals(errorCode, notFoundDetails.getErrorCode().name());
     }
 
     // type mismatch
@@ -309,6 +308,23 @@ public class StepDefinitions {
     @Then("the reason should indicate an error and the error code should indicate a type mismatch with {string}")
     public void the_reason_should_indicate_an_error_and_the_error_code_should_be_type_mismatch(String errorCode) {
         assertEquals(Reason.ERROR.toString(), typeErrorDetails.getReason());
-        assertTrue(typeErrorDetails.getErrorCode().name().equals(errorCode));
+        assertEquals(errorCode, typeErrorDetails.getErrorCode().name());
+    }
+
+    @SuppressWarnings("java:S2925")
+    @When("sleep for {int} milliseconds")
+    public void sleepForMilliseconds(int millis) {
+        long startTime = System.currentTimeMillis();
+        long endTime = startTime + millis;
+        long now;
+        while ((now = System.currentTimeMillis()) < endTime) {
+            long remainingTime = endTime - now;
+            try {
+                //noinspection BusyWait
+                Thread.sleep(remainingTime);
+            } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+            }
+        }
     }
 }