Skip to content

Testing API Reference

JUnit 5 extension and annotations for testing FlagZen features with pinned flag values.

Module: flagzen-test

Overview

The testing API provides:

  • FlagZenExtension — JUnit 5 extension for setting up test-scoped flag context
  • @PinFlag — declarative annotation for pinning flag values in tests
  • @FlagSource — load flag values from classpath properties files
  • TestFlagContext — programmatic API for setting and resolving flags

FlagZenExtension

JUnit 5 extension that manages test-scoped flag contexts and applies @PinFlag annotations.

Setup

testImplementation("com.flagzen:flagzen-test:1.1.0")

Usage

@ExtendWith(FlagZenExtension.class)
class MyFeatureTest {
    // tests here
}

Lifecycle

  1. Before each test method (BeforeEachCallback):
  2. Create a new TestFlagContext with an empty in-memory provider
  3. Load flags from @FlagSource if present on the test class
  4. Apply @PinFlag annotations from the test method
  5. Store context in extension namespace

  6. During test:

  7. Test can inject TestFlagContext or feature types as method parameters
  8. Extension resolves them via ParameterResolver

  9. After each test method (AfterEachCallback):

  10. Clean up the test context (remove from namespace)

Feature Type Discovery

The extension discovers all feature types via ServiceLoader<FeatureMetadata>. Feature types are those generated by the annotation processor for each @Feature interface.

@ExtendWith(FlagZenExtension.class)
class CheckoutTest {
    @Test
    void testCheckoutFlow(CheckoutFlow flow) {
        // CheckoutFlow is automatically discovered and resolved
        assertNotNull(flow);
    }
}

@PinFlag

Pins a feature flag to a specific variant value for the duration of a test method.

Annotation

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(PinFlags.class)
public @interface PinFlag {
    String feature();
    String variant();
}

Attributes

Attribute Type Description
feature String The feature flag key to pin (e.g., "checkout-flow")
variant String The variant value to pin (e.g., "CLASSIC")

Usage

@ExtendWith(FlagZenExtension.class)
class CheckoutTest {
    @Test
    @PinFlag(feature = "checkout-flow", variant = "CLASSIC")
    void testClassicFlow(CheckoutFlow flow) {
        assertEquals("classic", flow.execute());
    }

    @Test
    @PinFlag(feature = "checkout-flow", variant = "PREMIUM")
    void testPremiumFlow(CheckoutFlow flow) {
        assertEquals("premium", flow.execute());
    }
}

Multiple Pins

Use @Repeatable to pin multiple flags in one test:

@Test
@PinFlag(feature = "checkout-flow", variant = "PREMIUM")
@PinFlag(feature = "dark-mode", variant = "true")
void testPremiumDarkCheckout(CheckoutFlow flow, DarkMode theme) {
    assertEquals("premium", flow.execute());
    assertEquals("dark", theme.theme());
}

Priority

The priority order for flag values is:

  1. @PinFlag on the test method (highest priority)
  2. @FlagSource on the test class
  3. Empty provider (all flags absent)

If a flag is pinned by both @PinFlag and @FlagSource, the @PinFlag value wins.

@FlagSource

Loads flag values from a classpath properties file for all tests in the annotated class or method.

Annotation

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface FlagSource {
    String value();
}

Attributes

Attribute Type Description
value String Classpath resource path to the properties file (e.g., "flags.properties")

File Format

Plain Java properties file format. Each line is flag.key=variant.value:

checkout-flow=CLASSIC
dark-mode=true
max-retries=3

Usage

Class-level loading (applies to all test methods):

@ExtendWith(FlagZenExtension.class)
@FlagSource("test-flags.properties")
class AllCheckoutTests {
    @Test
    void testCheckoutFlow(CheckoutFlow flow) {
        assertEquals("CLASSIC", flow.execute());
    }
    // All tests use flags from test-flags.properties
}

Method-level override:

@ExtendWith(FlagZenExtension.class)
@FlagSource("default-flags.properties")
class AllCheckoutTests {
    @Test
    void testDefault(CheckoutFlow flow) {
        // Uses flags from default-flags.properties
    }

    @Test
    @FlagSource("premium-flags.properties")
    void testPremium(CheckoutFlow flow) {
        // Uses flags from premium-flags.properties (overrides class-level)
    }
}

Error Handling

  • Throws FlagZenException if the resource file is not found
  • Throws IllegalStateException if the file cannot be read
  • Logs the resource path in the error message for debugging

TestFlagContext

Programmatic API for pinning flag values and resolving features in tests.

Creating a Context

Empty context (for programmatic pin-then-test patterns):

TestFlagContext context = TestFlagContext.create();
context.pin("checkout-flow", "CLASSIC");

CheckoutFlow flow = context.resolve(CheckoutFlow.class);
assertEquals("classic", flow.execute());

From properties file:

TestFlagContext context = TestFlagContext.createFromProperties("test-flags.properties");

CheckoutFlow flow = context.resolve(CheckoutFlow.class);

Methods

pin(String key, String value)

Pins a flag to a value.

Signature

public void pin(String key, String value)

Parameters

  • key (String): The flag key
  • value (String): The variant value

Example

TestFlagContext context = TestFlagContext.create();
context.pin("checkout-flow", "CLASSIC");
context.pin("dark-mode", "true");
context.pin("max-retries", "5");

resolve(Class<T> featureType)

Resolves a feature interface to its proxy using the pinned flag values.

Signature

public <T> T resolve(Class<T> featureType)

Type Parameter

  • T: The feature interface type

Parameters

  • featureType (Class<T>): The feature interface class

Returns

  • A proxy delegating to the active variant based on pinned flag values

Example

TestFlagContext context = TestFlagContext.create();
context.pin("checkout-flow", "PREMIUM");

CheckoutFlow flow = context.resolve(CheckoutFlow.class);
assertEquals("premium", flow.execute());

Injection via Extension

Do not create TestFlagContext manually when using FlagZenExtension. Instead, inject it as a parameter:

@ExtendWith(FlagZenExtension.class)
class CheckoutTest {
    @Test
    void testWithContext(TestFlagContext context) {
        context.pin("checkout-flow", "CLASSIC");
        CheckoutFlow flow = context.resolve(CheckoutFlow.class);
        assertEquals("classic", flow.execute());
    }
}

Complete Example

@ExtendWith(FlagZenExtension.class)
@FlagSource("integration-test-flags.properties")
class CheckoutIntegrationTest {

    // Uses flags from integration-test-flags.properties
    @Test
    void testDefaultCheckout(CheckoutFlow flow) {
        String result = flow.execute();
        assertNotNull(result);
    }

    // Pins checkout-flow to CLASSIC, overrides @FlagSource
    @Test
    @PinFlag(feature = "checkout-flow", variant = "CLASSIC")
    void testClassicCheckout(CheckoutFlow flow) {
        assertEquals("classic", flow.execute());
    }

    // Pins multiple flags
    @Test
    @PinFlag(feature = "checkout-flow", variant = "PREMIUM")
    @PinFlag(feature = "dark-mode", variant = "true")
    void testPremiumDarkCheckout(
            CheckoutFlow flow,
            DarkMode theme,
            TestFlagContext context) {

        // Programmatically adjust if needed
        context.pin("max-retries", "10");

        assertEquals("premium", flow.execute());
        assertEquals("dark", theme.theme());
    }

    // Use TestFlagContext directly
    @Test
    void testProgrammaticFlags(TestFlagContext context) {
        context.pin("checkout-flow", "STREAMLINED");

        CheckoutFlow flow = context.resolve(CheckoutFlow.class);
        assertEquals("streamlined", flow.execute());
    }
}

Best Practices

  • Use @PinFlag for simple cases: When you just need to set one or two flags for a test.
  • Use @FlagSource for complex scenarios: When multiple tests share a large set of flag values, load from a properties file instead of repeating @PinFlag annotations.
  • Use TestFlagContext for dynamic scenarios: When you need to change flag values during a test or set them conditionally.
  • Name properties files clearly: Use names like test-flags.properties, smoke-test-flags.properties to indicate their purpose.
  • Keep flags organized: Group related flags in the same properties file or in the same test class.
  • Test both true and false for booleans: Create separate test methods pinning boolean features to both "true" and "false".
  • Test variants independently: Create one test per variant to ensure each variant implementation works correctly.

Thread Safety

  • TestFlagContext is thread-scoped via the FlagZenExtension
  • Each test method gets its own context
  • Safe for parallel test execution (each test is isolated)
  • FlagZenExtension — JUnit 5 integration
  • @PinFlag — declarative flag pinning
  • @FlagSource — properties file loading
  • TestFlagContext — programmatic API
  • FeatureDispatcher — runtime dispatch