Skip to content

feat: Add JUnit5 extension for OpenFeature #888

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 10 commits into from
Jul 26, 2024
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