Skip to content
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

<modules>
<module>hooks/open-telemetry</module>
<module>tools/junit-openfeature</module>
<module>providers/flagd</module>
<module>providers/flagsmith</module>
<module>providers/go-feature-flag</module>
Expand Down
2 changes: 1 addition & 1 deletion providers/flagd/schemas
2 changes: 1 addition & 1 deletion providers/flagd/test-harness
156 changes: 156 additions & 0 deletions tools/junit-openfeature/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# JUnit Open Feature extension

A JUnit5 extension to reduce boilerplate code for testing code which utilizes OpenFeature.

## Getting Started

We are supporting two different flavors for testing, a [simple](#simple-configuration) and an [extended](#extended-configuration) configuration.

Notice: We are most likely not multithread compatible!
### Simple Configuration

Choose the simple configuration if you are only testing in one domain.
Per default, it will be used in the global domain.

```java
@Test
@Flag(name = "BOOLEAN_FLAG", value = "true")
void test() {
// your test code
}
```

#### Multiple flags

The `@Flag` annotation can be also repeated multiple times.

```java
@Test
@Flag(name = "BOOLEAN_FLAG", value = "true")
@Flag(name = "BOOLEAN_FLAG2", value = "true")
void test() {
// your test code
}
```

#### Defining Flags for a whole test-class

`@Flags` can be defined on the class-level too, but method-level
annotations will superseded class-level annotations.

```java
@Flag(name = "BOOLEAN_FLAG", value = "true")
@Flag(name = "BOOLEAN_FLAG2", value = "false")
class Test {
@Test
@Flag(name = "BOOLEAN_FLAG2", value = "true") // will be used
void test() {
// your test code
}
}
```

#### Setting a different domain

You can define an own domain on the test-class-level with `@OpenFeatureDefaultDomain` like:

```java
@OpenFeatureDefaultDomain("domain")
class Test {
@Test
@Flag(name = "BOOLEAN_FLAG", value = "true")
// this flag will be available in the `domain` domain
void test() {
// your test code
}
}
```

### Extended Configuration

Use the extended configuration when your code needs to use multiple domains.

```java
@Test
@OpenFeature({
@Flag(name = "BOOLEAN_FLAG", value = "true")
})
void test() {
// your test code
}
```


#### Multiple flags

The `@Flag` annotation can be also repeated multiple times.

```java
@Test
@OpenFeature({
@Flag(name = "BOOLEAN_FLAG", value = "true"),
@Flag(name = "BOOLEAN_FLAG2", value = "true")
})
void test() {
// your test code
}
```

#### Defining Flags for a whole test-class

`@Flags` can be defined on the class-level too, but method-level
annotations will superseded class-level annotations.

```java
@OpenFeature({
@Flag(name = "BOOLEAN_FLAG", value = "true"),
@Flag(name = "BOOLEAN_FLAG2", value = "false")
})
class Test {
@Test
@OpenFeature({
@Flag(name = "BOOLEAN_FLAG2", value = "true") // will be used
})
void test() {
// your test code
}
}
```

#### Setting a different domain

You can define an own domain for each usage of the `@OpenFeature` annotation with the `domain` property:

```java
@Test
@OpenFeature(
domain = "domain",
value = {
@Flag(name = "BOOLEAN_FLAG2", value = "true") // will be used
})
// this flag will be available in the `domain` domain
void test() {
// your test code
}
```

#### Multiple Configurations for multiple domains

Following testcode will generate two providers, with different flag configurations for a test.

```java
@Test
@OpenFeature({
@Flag(name = "BOOLEAN_FLAG", value = "true"),
@Flag(name = "BOOLEAN_FLAG2", value = "true")
})
@OpenFeature(
domain = "domain",
value = {
@Flag(name = "BOOLEAN_FLAG2", value = "true") // will be used
})
void test() {
// your test code
}
```

49 changes: 49 additions & 0 deletions tools/junit-openfeature/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.openfeature.contrib</groupId>
<artifactId>parent</artifactId>
<version>0.1.0</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<groupId>dev.openfeature.contrib.tools</groupId>
<artifactId>junitopenfeature</artifactId>
<version>0.0.1</version> <!--x-release-please-version -->

<name>junit-openfeature-extension</name>
<description>JUnit5 Extension for OpenFeature</description>
<url>https://openfeature.dev</url>

<developers>
<developer>
<id>aepfli</id>
<name>Simon Schrottner</name>
<organization>OpenFeature</organization>
<url>https://openfeature.dev/</url>
</developer>
</developers>

<dependencies>
<dependency>
<groupId>dev.openfeature</groupId>
<artifactId>sdk</artifactId>
<version>[1.4,2.0)</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>

<dependency>
<groupId>org.junit-pioneer</groupId>
<artifactId>junit-pioneer</artifactId>
<version>1.9.1</version>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package dev.openfeature.contrib.tools.junitopenfeature;

import dev.openfeature.contrib.tools.junitopenfeature.annotations.OpenFeature;
import dev.openfeature.contrib.tools.junitopenfeature.annotations.OpenFeatureDefaultDomain;
import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.sdk.providers.memory.Flag;
import org.apache.commons.lang3.BooleanUtils;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.InvocationInterceptor;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
import org.junitpioneer.internal.PioneerAnnotationUtils;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

/**
* JUnit5 Extension for OpenFeature.
*/
public class OpenFeatureExtension implements BeforeEachCallback, AfterEachCallback, InvocationInterceptor {

OpenFeatureAPI api = OpenFeatureAPI.getInstance();

private static Map<String, Map<String, Flag<?>>> handleExtendedConfiguration(
ExtensionContext extensionContext,
Map<String, Map<String, Flag<?>>> configuration
) {
PioneerAnnotationUtils
.findAllEnclosingRepeatableAnnotations(extensionContext, OpenFeature.class)
.forEachOrdered(annotation -> {
Map<String, Flag<?>> domainFlags = configuration.getOrDefault(annotation.domain(), new HashMap<>());

Arrays.stream(annotation.value())
.filter(flag -> !domainFlags.containsKey(flag.name()))
.forEach(flag -> {
Flag.FlagBuilder<?> builder = generateFlagBuilder(flag);
domainFlags.put(flag.name(), builder.build());
});
configuration.put(annotation.domain(), domainFlags);
});
return configuration;
}

private static Map<String, Map<String, Flag<?>>> handleSimpleConfiguration(ExtensionContext extensionContext) {
Map<String, Map<String, Flag<?>>> configuration = new HashMap<>();
String defaultDomain = PioneerAnnotationUtils
.findClosestEnclosingAnnotation(extensionContext, OpenFeatureDefaultDomain.class)
.map(OpenFeatureDefaultDomain::value).orElse("");
PioneerAnnotationUtils
.findAllEnclosingRepeatableAnnotations(
extensionContext,
dev.openfeature.contrib.tools.junitopenfeature.annotations.Flag.class)
.forEachOrdered(flag -> {
Map<String, Flag<?>> domainFlags = configuration.getOrDefault(defaultDomain, new HashMap<>());
if (!domainFlags.containsKey(flag.name())) {
Flag.FlagBuilder<?> builder = generateFlagBuilder(flag);
domainFlags.put(flag.name(), builder.build());
configuration.put(defaultDomain, domainFlags);
}
});

return configuration;
}

private static Flag.FlagBuilder<?> generateFlagBuilder(
dev.openfeature.contrib.tools.junitopenfeature.annotations.Flag flag
) {
Flag.FlagBuilder<?> builder;
switch (flag.valueType().getSimpleName()) {
case "Boolean":
builder = Flag.<Boolean>builder();
builder.variant(flag.value(), BooleanUtils.toBoolean(flag.value()));
break;
case "String":
builder = Flag.<String>builder();
builder.variant(flag.value(), flag.value());
break;
case "Integer":
builder = Flag.<Integer>builder();
builder.variant(flag.value(), Integer.parseInt(flag.value()));
break;
case "Double":
builder = Flag.<Double>builder();
builder.variant(flag.value(), Double.parseDouble(flag.value()));
break;
default:
throw new IllegalArgumentException("Unsupported flag type: " + flag.value());
}
builder.defaultVariant(flag.value());
return builder;
}

@Override
public void interceptTestMethod(
Invocation<Void> invocation,
ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext
) throws Throwable {
TestProvider.CURRENT_NAMESPACE.set(getNamespace(extensionContext));
invocation.proceed();
TestProvider.CURRENT_NAMESPACE.remove();
}

@Override
public void afterEach(ExtensionContext extensionContext) throws Exception {
}

@Override
public void beforeEach(ExtensionContext extensionContext) throws Exception {
Map<String, Map<String, Flag<?>>> configuration = handleSimpleConfiguration(extensionContext);
configuration.putAll(handleExtendedConfiguration(extensionContext, configuration));

for (Map.Entry<String, Map<String, Flag<?>>> stringMapEntry : configuration.entrySet()) {

if (!stringMapEntry.getKey().isEmpty()) {
String domain = stringMapEntry.getKey();
if (api.getProvider(domain) instanceof TestProvider && api.getProvider(domain) != api.getProvider()) {
((TestProvider) api.getProvider(domain))
.addFlags(getNamespace(extensionContext), stringMapEntry.getValue());
} else {
api.setProvider(domain, new TestProvider(
getNamespace(extensionContext),
stringMapEntry.getValue()));
}
} else {
if (api.getProvider() instanceof TestProvider) {
((TestProvider) api.getProvider())
.addFlags(getNamespace(extensionContext), stringMapEntry.getValue());
} else {
api.setProvider(new TestProvider(
getNamespace(extensionContext),
stringMapEntry.getValue()));
}
}

}

getStore(extensionContext).put("config", configuration);

}

private ExtensionContext.Namespace getNamespace(ExtensionContext extensionContext) {
return ExtensionContext.Namespace.create(
getClass(),
extensionContext.getRequiredTestMethod()
);
}

private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(ExtensionContext.Namespace.create(getClass()));
}
}
Loading