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-harnessfeature-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:
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
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
Step 1 - Classify each flag (Hodgson taxonomy)
Per feature-toggles (opens in new window), flags fall into four categories with different test needs:
| Category | Lifespan | Dynamism | Test combinations needed |
|---|---|---|---|
| Release toggle | Days - weeks | Static at deploy | OFF (current) and ON (new behavior). 2 runs. |
| Experiment toggle | Days - weeks | Per-request dynamic | One run per variant (A / B / control). |
| Ops toggle | Long-lived | Per-request dynamic | ON (normal) and OFF (degraded / kill). |
| Permissioning toggle | Years | Per-request dynamic | One 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-pattern | Why it fails | Fix |
|---|---|---|
| Running the full 2^N cartesian product | N=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 tests | Non-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 harness | Each 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 matrix | First failure cancels all other combos; team has to re-run to see the rest. | fail-fast: false. |
| Missing the baseline (all-flags-default) row | Can'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/off | Misses 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
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
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.