Testland
Browse all skills & agents

feature-flag-test-harness

Builds a test harness that runs the same suite under every relevant flag combination - picks the minimum cover (single flags + pairwise interactions where the team marks them, not the full 2^N cartesian product), wires an OpenFeature in-memory provider so the suite never hits the production flag service, runs each combination as its own labeled CI matrix shard, and emits a per-combination result matrix. Use when a feature behind a flag must be verified on AND off (release toggles + experiment toggles per Hodgson) and the team wants those runs deterministic and parallel.

Install with skills.sh (any agent)

npx skills add testland/qa --skill feature-flag-test-harness
View source

feature-flag-test-harness

Overview

A test that hits the production flag service is non-deterministic by definition - the answer depends on whoever toggled the flag last. And a test that asks "did we test the feature with the flag off?" needs both runs side by side.

This skill builds a harness that:

  1. Replaces the production OpenFeature provider with an in-memory provider the test owns (openfeature-providers (opens in new window)).
  2. Enumerates the relevant flag combinations (not the full 2^N cartesian product - the long tail isn't worth running).
  3. Runs the suite once per combination, as a separate CI shard.
  4. Aggregates results into a matrix the reviewer can read.

The skill's reference architecture targets OpenFeature because it standardizes the SDK across LaunchDarkly, Flagsmith, ConfigCat, self-hosted, etc. - the harness works identically against any provider (openfeature-overview (opens in new window)).

When to use

  • A new feature lives behind a release toggle and the team needs to verify both code paths (toggle off = old behavior unchanged; toggle on = new behavior correct).
  • An experiment toggle has multiple variants (A / B / control) and each needs an integration-test pass.
  • An ops toggle (kill switch / degradation flag) needs a "service fails open" pass.
  • The flag set already exists in OpenFeature, LaunchDarkly, Flagsmith, ConfigCat, GrowthBook, or any SDK with an in-memory / test provider.

If the team has only one or two flags and a flat "always on for test" config works, this skill is overkill - set the test environment's flag values once in setup and stop there.

How to use

  1. Classify each flag by Hodgson category (Step 1) and mark which flag pairs actually interact.
  2. Enumerate the minimum cover - single-flag variants plus the declared interaction tuples, never the full 2^N product.
  3. Wire the OpenFeature in-memory provider so the suite never hits the production flag service - see references/provider-wiring.md.
  4. Generate the combination matrix and run one CI shard per combination - see references/matrix-and-ci.md.
  5. Keep fail-fast: false on the matrix so every failing combination surfaces in one run, not just the first.
  6. Aggregate each shard's JUnit XML into a per-combination pass/fail matrix the reviewer reads at a glance.
  7. Split cadence: PR runs only combinations whose flags touch changed files; nightly runs the full matrix.

Step 1 - Classify each flag (Hodgson taxonomy)

Per feature-toggles (opens in new window), flags fall into four categories with different test needs:

CategoryLifespanDynamismTest combinations needed
Release toggleDays - weeksStatic at deployOFF (current) and ON (new behavior). 2 runs.
Experiment toggleDays - weeksPer-request dynamicOne run per variant (A / B / control).
Ops toggleLong-livedPer-request dynamicON (normal) and OFF (degraded / kill).
Permissioning toggleYearsPer-request dynamicOne run per relevant user cohort.

For experiment and permissioning toggles, the harness simulates each cohort by seeding the EvaluationContext (feature-toggles (opens in new window)).

Don't run all 2^N combinations. Author marks the interactions worth testing:

# tests/flag-matrix.yaml
flags:
  new_checkout:        { kind: release,  test: [off, on] }
  promo_codes:         { kind: release,  test: [off, on] }
  ranking_experiment:  { kind: experiment, variants: [control, treatment_a, treatment_b] }
  payment_kill_switch: { kind: ops,      test: [on, off] }   # off = degraded

interactions:
  # The author asserts these flag pairs interact; run their combinations explicitly.
  - [new_checkout, promo_codes]
  # Ranking experiment doesn't interact with checkout; don't bloat the matrix.

The harness enumerates every flag's variants individually plus the listed interaction tuples, never the full 2^N product. See the Worked example for the run count this schema yields.

Provider wiring, matrix generation, CI

Wire the in-memory provider so the suite reads flag values from FLAGS_JSON instead of the production service (Node shown; Python and Java variants in the reference):

// tests/harness/flag-harness.ts
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

export function withFlags(flags: Record<string, unknown>) {
  return OpenFeature.setProviderAndWait(new InMemoryProvider(
    Object.fromEntries(Object.entries(flags).map(([k, v]) => [k, {
      defaultVariant: 'configured', variants: { configured: v }, disabled: false,
    }])),
  ));
}
// beforeAll(() => withFlags(JSON.parse(process.env.FLAGS_JSON || '{}')));

Generate one shard per combination, then feed the JSON to the CI matrix:

python scripts/gen-flag-matrix.py tests/flag-matrix.yaml   # -> JSON array of {name, flags}

Worked example

A checkout team ships new_checkout (release) and promo_codes (release) behind flags, runs a ranking_experiment (control / treatment_a / treatment_b), and guards payments with a payment_kill_switch (ops). They author tests/flag-matrix.yaml exactly as in Step 1 and declare the one interaction that matters: [new_checkout, promo_codes].

gen-flag-matrix.py enumerates 9 single-flag runs (2 + 2 + 3 + 2) plus 4 interaction runs (new_checkout × promo_codes) = 13 shards, not the 24 of the full 2^N product. Each shard boots the suite with the in-memory provider pinned to that combination via FLAGS_JSON, so no run touches the production flag service.

CI runs all 13 shards in parallel with fail-fast: false. The aggregated matrix shows promo_codes=on red at checkout.spec.ts:42 and ranking_experiment=treatment_b red at cart.spec.ts:18, while the baseline and every other combination pass. The team reads two flag-specific failures in one glance instead of re-running to find the second.

Anti-patterns and limitations

The full anti-pattern table (2^N blowup, hitting the prod provider, asserting flag value instead of behavior, fail-fast: true, missing baseline row) and the harness's limitations (no targeting rules, author-declared interactions, per-request dynamism, matrix-size bound) are in references/anti-patterns-and-limits.md.

References

Anti-patterns and limitations

View source (opens in new window)

Anti-patterns and limitations

Anti-patterns

Anti-patternWhy it failsFix
Running the full 2^N cartesian productN=10 flags = 1024 shards; CI bill explodes; most combinations are irrelevant.Single-flag variants + author-declared interaction tuples (Step 1).
Hitting the production OpenFeature provider from testsNon-deterministic; flaky; depends on whoever toggled last.InMemoryProvider per openfeature-providers (opens in new window).
Hard-coding flag values in the test instead of the harnessEach test re-implements the harness; drift; one test forgets to set a flag.Centralize in flag-harness.ts/.py/.java; tests just assert behavior.
Asserting flag value in the test (expect(client.getBooleanValue('new_checkout', false)).toBe(true))Tests the SDK, not the feature. The harness already pinned the value.Assert the observable behavior the flag controls (DOM state, response shape, log line).
fail-fast: true on the matrixFirst failure cancels all other combos; team has to re-run to see the rest.fail-fast: false.
Missing the baseline (all-flags-default) rowCan't tell whether a failure is flag-specific or a regression on default state.Always emit a baseline combination as combo #1.
Treating ranking_experiment variants as a binary on/offMisses variant-specific bugs (e.g., treatment_b breaks but treatment_a passes).Enumerate every variant per feature-toggles (opens in new window) cohort logic.

Limitations

  • In-memory provider doesn't model targeting rules. Real LaunchDarkly / Flagsmith may use percentage rollouts, country matches, or user-attribute targeting. The harness lets the test pin a value; verifying the targeting logic itself needs a contract test against the real provider's API (see qa-contract-testing).
  • Author has to declare interactions. The harness can't infer which flag pairs interact; it relies on the YAML. A missing interaction means a missed bug class.
  • Per-request dynamism not exercised by single-shard runs. Experiment / permissioning toggles change per-user; a shard pins one value. To test mid-request flag flips, write targeted unit tests against the toggle router, not E2E.
  • CI matrix size is bounded by max-parallel. Above ~50 shards, scheduling overhead dominates; consider sharding by suite rather than by combination.

Matrix generation, CI wiring, aggregation, cadence

View source (opens in new window)

Matrix generation, CI wiring, aggregation, cadence

Generate the combination matrix

A small generator script enumerates the combinations from flag-matrix.yaml:

# scripts/gen-flag-matrix.py
import json, sys, yaml
from itertools import product

cfg = yaml.safe_load(open(sys.argv[1]))
combos = []

# Single-flag variants
for flag, spec in cfg['flags'].items():
    base = {f: defaultFor(s) for f, s in cfg['flags'].items()}
    for variant in spec.get('test', spec.get('variants', [])):
        combo = dict(base)
        combo[flag] = variant
        combos.append({'name': f'{flag}={variant}', 'flags': combo})

# Declared interactions
for tuple_flags in cfg.get('interactions', []):
    spaces = [cfg['flags'][f].get('test', cfg['flags'][f].get('variants', [])) for f in tuple_flags]
    base = {f: defaultFor(s) for f, s in cfg['flags'].items()}
    for combo_values in product(*spaces):
        combo = dict(base)
        for f, v in zip(tuple_flags, combo_values):
            combo[f] = v
        combos.append({
            'name': '+'.join(f'{f}={v}' for f, v in zip(tuple_flags, combo_values)),
            'flags': combo,
        })

print(json.dumps(combos, indent=2))

def defaultFor(spec):
    if 'test' in spec: return spec['test'][0]   # first listed variant is the baseline
    return spec['variants'][0]

Output: a JSON array of {name, flags} objects, one per CI shard.

Wire the CI matrix

# .github/workflows/flag-harness.yml
name: flag-harness
on:
  pull_request:
    paths:
      - 'tests/flag-matrix.yaml'
      - 'src/**'

jobs:
  generate:
    runs-on: ubuntu-latest
    outputs:
      combos: ${{ steps.gen.outputs.combos }}
    steps:
      - uses: actions/checkout@v5
      - id: gen
        run: |
          combos=$(python scripts/gen-flag-matrix.py tests/flag-matrix.yaml)
          echo "combos=$combos" >> "$GITHUB_OUTPUT"

  test:
    needs: generate
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 8
      matrix:
        combo: ${{ fromJSON(needs.generate.outputs.combos) }}
    name: test (${{ matrix.combo.name }})
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm test
        env:
          FLAGS_JSON: ${{ toJSON(matrix.combo.flags) }}

fail-fast: false is load-bearing - when one combination fails, the matrix continues so the team sees every failing combination at once, not just the first.

Aggregate the result matrix

After the matrix runs, build a single artifact that shows pass/fail per combination:

## Flag harness results - `<sha>`

| Combination                                          | Result | Failures              |
|------------------------------------------------------|:------:|-----------------------|
| baseline (all flags = baseline)                       |   ✅   |                       |
| new_checkout=on                                       |   ✅   |                       |
| new_checkout=off                                      |   ✅   |                       |
| promo_codes=on                                        |   ❌   | `checkout.spec.ts:42` |
| ranking_experiment=treatment_a                        |   ✅   |                       |
| ranking_experiment=treatment_b                        |   ❌   | `cart.spec.ts:18`     |
| new_checkout=on + promo_codes=on                      |   ❌   | `checkout.spec.ts:42`, `promo.spec.ts:7` |
| payment_kill_switch=off                               |   ✅   |                       |

The aggregator reads each shard's JUnit XML, groups by combo name, emits the table. Failures column links to the failing test files for quick triage.

Pre-merge / nightly cadence

  • Pre-merge (PR): Run only the combinations whose flags appear in changed files. The flag-matrix YAML lists owner files per flag:
    flags:
      new_checkout: { ..., owners: [src/checkout/**] }
    
    The CI step git diff --name-only origin/main...HEAD | grep -f owners.glob selects.
  • Nightly: Run the full matrix. Catches drift in flags whose owner files weren't touched but whose behavior was affected by upstream code.

This split keeps PR runtime bounded while still gaining full coverage every 24h.

Provider wiring - OpenFeature in-memory provider

View source (opens in new window)

Provider wiring - OpenFeature in-memory provider

Per openfeature-providers (opens in new window), "Providers are responsible for performing flag evaluations" - the in-memory test provider returns the flag values the test wants.

Node / TypeScript

// tests/harness/flag-harness.ts
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

export function withFlags(flags: Record<string, unknown>) {
  const provider = new InMemoryProvider(
    Object.fromEntries(
      Object.entries(flags).map(([k, v]) => [k, {
        defaultVariant: 'configured',
        variants: { configured: v },
        disabled: false,
      }]),
    ),
  );
  return OpenFeature.setProviderAndWait(provider);
}

Then in the test setup:

import { withFlags } from './harness/flag-harness';

beforeAll(async () => {
  await withFlags(JSON.parse(process.env.FLAGS_JSON || '{}'));
});

Python

# tests/harness/flag_harness.py
from openfeature.api import set_provider
from openfeature.provider.in_memory_provider import InMemoryProvider, InMemoryFlag

def with_flags(flags: dict):
    set_provider(InMemoryProvider({
        k: InMemoryFlag(default_variant='configured',
                        variants={'configured': v})
        for k, v in flags.items()
    }))

Java

import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.contrib.providers.memory.InMemoryProvider;

@BeforeAll
static void wireFlags() {
    var flags = parseEnv(System.getenv("FLAGS_JSON"));  // your JSON parser
    OpenFeatureAPI.getInstance().setProvider(new InMemoryProvider(flags));
}

Evaluation API

The application code calls the standard OpenFeature evaluation API (openfeature-eval (opens in new window)):

const client = OpenFeature.getClient();
const enabled = await client.getBooleanValue('new_checkout', false);

Per openfeature-eval (opens in new window): "the default value must also be specified ... In the case of any error during flag evaluation, the default value will be returned, so give consideration to your default values!" The harness picks the value the in-memory provider returns; the application's hard-coded default is what runs in prod-flag-failure scenarios.

Related skills

docker-compose-tests

Authors a `compose.test.yaml` for tests - declares the SUT plus its real backing services as one declarative topology, wires healthcheck-driven `depends_on: condition: service_healthy` start ordering, isolates parallel CI jobs via per-job `--project-name`, gates the test step on `--wait` / `--wait-timeout` / `--exit-code-from`, and tears the stack down deterministically with `down --volumes --remove-orphans`. Use when the test environment is multi-service (app + db + cache + queue) and the topology is best expressed in YAML rather than imperative test code.

playwright-fixture-builder

Builds reusable Playwright fixtures via `test.extend` - picks the right scope (test vs worker), wires the `use(value)` setup/teardown split, composes auth (storageState per worker), database (per-test snapshot/restore), and feature-flag fixtures into one custom `test` object the whole suite imports. Outputs the `fixtures.ts` file plus per-fixture review notes (scope rationale, teardown ordering, `workerInfo.workerIndex` for parallel isolation). Use when the suite has copy-pasted `beforeEach` boilerplate that should be a fixture, or when adding auth / db / flag setup that crosses many specs.

testcontainers

Brings up real backing services (databases, message brokers, browsers, anything dockerizable) as throwaway containers from inside a test process - Java, Node.js, Python, Go, .NET, Ruby and ten other languages - using the Testcontainers library family. Wires the per-test container lifecycle, exposed-port → host-port mapping, wait strategies (port / log / HTTP / SQL), Ryuk-based cleanup, container-to-container networks, and the (experimental) `withReuse` shortcut for local dev. Use when integration tests need a real Postgres / Redis / Kafka / Selenium / etc. and the team wants per-test isolation without hand-rolled docker-compose teardown.