Feature-flag test matrix design: the flag-state combinatorics problem (N flags × M variants × K user-segments = N×M×K test cases), the canonical coverage strategies (pairwise interaction coverage; default-only smoke; full matrix; risk-driven matrix), the workflow for building the coverage suite from a flag inventory (grep-based inventory, per-flag classification, PICT pairwise generation, per-cell test skeletons), the dedicated kill-switch test categories (references/killswitch.md: graceful degradation, fail-static default, kill latency, mid-flight consistency), and the flags-vs-experiments distinction. Use when designing the flag-test surface for a new project, building or auditing flag-test coverage, or authoring kill-switch tests.
66
83%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
A codebase with N feature flags, each having M variants, and users in K segments, has N × M × K possible flag-state-segment combinations. At realistic numbers (50 flags, 2 variants each, 5 segments) that's 500 - and at 50 flags with 3 variants and 10 segments, it's 1500. Testing every combination is infeasible, so the matrix has to be sampled deliberately rather than enumerated.
This skill is both the reference (the combinatorics + strategies below) and
the coverage-suite-building workflow (see Building the coverage suite); the
per-SDK test mechanics live in launchdarkly-testing and
openfeature-sdk-testing.
launchdarkly-testing, or openfeature-sdk-testing for OpenFeature / Unleash / Flagsmith / GrowthBook) to implement each case.| Variable | Typical scale |
|---|---|
| Total flags in codebase | 20-500 |
| Variants per flag | 2 (most), 3-5 (experiments), 10+ (multivariate) |
| User segments | 5-20 (free, paid, enterprise, internal, beta, etc.) |
| Combinatorial total | Quickly enters thousands |
Insight: most flag combinations are inert (independent). Only a small subset interact - the test matrix should target interactions.
Test only the default-value combination ("all flags off" or "all flags at default"). Fast but misses everything.
Use when: flag-heavy codebase where defaults change rarely.
For each flag, test default + each variant in isolation. N × M tests; ignores interactions.
Use when: flags are mostly independent (UI tweaks, language strings, low-risk).
Test every pair of flags (combinatorial 2-way coverage). Per NIST SP 800-142 on combinatorial testing, pairwise catches ~67% of real defects with O(N²) combinations.
Use when: flags are known-interacting (auth + permissions, billing + plan-tier).
Implementation: tools like pict (Microsoft) generate the
pairwise matrix from a flag inventory.
Every combination. N^M tests for boolean flags.
Use when: small (≤10) flag count with strong interaction; financial / regulatory paths.
Custom matrix targeting (flag, segment) cells with known risk
(per a risk register per risk-matrix in the qa-process plugin).
Use when: any non-trivial codebase. Best in practice.
The workflow that turns the strategies above into a committed matrix + test skeletons:
grep -rn 'isOn\|isEnabled\|variation\|getFeatureValue' --include='*.{ts,js,py,go,java}' .flags:
- name: show-new-ui
platform: launchdarkly
type: boolean
found_at: [src/components/Header.tsx:42, src/pages/Dashboard.tsx:88]
- name: checkout-experiment
type: multi-variant
variants: [control, treatment-a, treatment-b]*-kill, disable-*,
emergency-*), experiment (multi-variant + analytics), permission-gated,
UI tweak, migration (use-new-*), plan/tier gating. The class picks the
strategy: default-only smoke for UI tweaks, per-flag isolation for
migrations, pairwise for permission/plan interactions, full matrix for
kill-switches + regulatory paths, risk-driven for the rest.pict pict.txt > matrix.tsv
emits a pairwise-covering matrix, e.g. ≤12 tests instead of 24 for full);
for risk-driven, combine with the risk register (risk-matrix in the
qa-process plugin) - high-impact x high-likelihood cells become required
tests.describe('auth flag matrix', () => {
test('free user, new auth on → new flow', () => {
td.update(td.flag('use-new-auth').booleanFlag().on(true));
expect(authFlow({ plan: 'free' })).toBe('new');
});
});flag-coverage.yaml in the
repo, plus a coverage doc listing covered cells and deliberate gaps with
reasons, so drift is reviewable.| Category | Test |
|---|---|
| Kill-switch | Setting flag → off must halt the feature within N seconds (cache TTL); full four-category treatment in references/killswitch.md |
| Percentage rollout | Flag at 10% → ~10% of users in 'on' bucket; SDK assignment stable per user |
| Targeted rollout | Targeting region=EU → only EU users get treatment |
| Sticky assignment | Same user → same variant across sessions and re-launches |
| Override hierarchy | User-specific override > segment override > default |
| Default-on-error | SDK fails / network down → default value returned |
| Fast-deactivate | Toggle flag off → live users see new state on next evaluation |
These are per-platform behaviours (LaunchDarkly, Unleash, Flagsmith, GrowthBook implement them differently); test per platform per the SDK skills.
Tests should run at multiple layers:
| Layer | Coverage |
|---|---|
| Unit | Resolver / handler logic gated on flag value (mock SDK) |
| Integration | SDK + handler together (test SDK against local-eval / fixture) |
| E2E | Real flag toggle → real user sees the change |
| Production smoke | After flag change → assert expected behaviour live |
Per ab-test-validity-checklist (in this plugin):
| Flag | Experiment |
|---|---|
| Toggles behaviour | Measures outcome |
| Boolean / multi-variant | Multi-arm with metrics |
| Test the behaviour change | Test the assignment + outcome correlation |
| Ship decision on engineering judgment | Ship decision on statistical result |
A feature flag can power an experiment (the flag is the allocation mechanism), but tests are layered: flag tests verify correct behaviour per variant; experiment tests verify assignment + analytics.
A billing service has 3 boolean flags - new_checkout, annual_discount, tax_engine_v2 - across 3 segments (free, paid, enterprise). The team knows annual_discount and tax_engine_v2 interact: a discount changes the taxable amount.
annual_discount × tax_engine_v2 pair as known-interacting; new_checkout is a UI change treated as independent.new_checkout; a pairwise cell covers (annual_discount on, tax_engine_v2 on) on the paid and enterprise segments where money moves.tax_engine_v2 off reverts to v1 within the cache TTL; a sticky-assignment test asserts the annual_discount percentage rollout keeps each user in the same bucket across sessions.tax_engine_v2 and asserts an enterprise invoice total.Result: a handful of targeted cases instead of the full cross-product, with the discount × tax interaction covered explicitly.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only default-value path | Misses every flag-on case | Per-flag isolation minimum |
| Mock the SDK to return constant | Misses targeting / rollout logic | Local-eval mode or fixture-based SDK |
| Same test for every flag combination | Slow; flaky; opaque failures | Per-combination assertion logs |
| No kill-switch test | Production incident has no rehearsed response | Test deactivation latency |
| Don't test percentage-rollout sticky-assignment | Rollout produces non-deterministic UX | Per ab-test-validity-checklist |
| Tests assume flag-on default | Real default-off behaviour untested in CI | Test both paths |
| No cleanup test for removed flags | Stale flags accumulate | Periodic audit via the stale-flag-detector agent |
| Pairwise without flag-interaction discovery | Some pairs spuriously interact | Couple with risk-register input |
stale-flag-detector agent's removal runbook.launchdarkly-testing,
openfeature-sdk-testing.stale-flag-detector agent.ab-test-validity-checklist,
feature-flag-test-harness.