How to check feature flags in test environment
Run the suite twice, once with the flag on and once with it off, and compare. FEATURE_NEW_CHECKOUT=off npx vitest run has to change a result. If both runs pass identically, the tests never reached the flagged branch. Force the value with vi.stubEnv, then assert which branch ran.
Why check this
Run this before a flag is switched on in production, and again before the old branch is deleted. The failure it prevents: the release plan says the new checkout is covered by 40 tests, the flag default in config/features.json was flipped to true six weeks ago, and every one of those 40 tests has been exercising the new branch ever since. The rollback path is untested, and nobody finds out until the flag is turned off during an incident.
A flag test answers two questions, and most suites answer only the first. Does the output match the expectation, and did the branch under test actually run. Both branches often agree on the input a test happens to use, so a green assertion is not evidence that the intended code path executed.
Prerequisites
- Node 22 and vitest. The runs below used vitest 5.0.0, invoked from the repository
node_modules. - A resolver that reads the flag on every call. A module level constant cannot be changed after import, which step 3 shows.
- The fixture below.
config/features.jsonholds{ "new_checkout": true, "bulk_discount": false }.
// src/flags.mjs
import { readFileSync } from 'node:fs';
const path = new URL('../config/features.json', import.meta.url);
export function isEnabled(name) {
const override = process.env[`FEATURE_${name.toUpperCase()}`];
if (override !== undefined) return override === 'on';
return JSON.parse(readFileSync(path, 'utf8'))[name] === true;
}
// src/flags-cached.mjs, the shape that cannot be forced
import { isEnabled } from './flags.mjs';
export const NEW_CHECKOUT = isEnabled('new_checkout');
// src/quote.mjs
export const branchLog = [];
export function quote(items) {
if (isEnabled('new_checkout')) {
branchLog.push('new');
let cents = items.reduce((a, i) => a + Math.round(i.price * 100) * i.qty, 0);
if (isEnabled('bulk_discount') && cents > 10000) cents = Math.round(cents * 0.9);
return { total: cents / 100, currency: 'EUR' };
}
branchLog.push('legacy');
let total = 0;
for (const i of items) total += i.price * i.qty;
return { total, currency: 'EUR' };
}
The two branches differ only in float handling. The new one sums integer cents, the old one sums floats, so 1.10 x 3 is 3.3 in one and 3.3000000000000003 in the other.
Steps
- Step 1.
Run the suite the way CI runs it, with the flag at its config default.
npx vitest run tests/quote.test.mjs --reporter=verbose✓ tests/quote.test.mjs > three items at 1.10 total 3.30 2ms Test Files 1 passed (1) Tests 1 passed (1) - Step 2.
Run the same file with the flag forced off.
FEATURE_NEW_CHECKOUT=off npx vitest run tests/quote.test.mjs --reporter=verbose× tests/quote.test.mjs > three items at 1.10 total 3.30 8ms → expected 3.3000000000000003 to be 3.3 // Object.is equality … - Expected + Received - 3.3 + 3.3000000000000003 Test Files 1 failed (1) Tests 1 failed (1)A suite that produces the same summary in both runs has not been reading the flag at all.
- Step 3.
Try to force a flag that the module read once, at import time.
// tests/cached.test.mjs import { test, expect, beforeEach } from 'vitest'; import { NEW_CHECKOUT } from '../src/flags-cached.mjs'; beforeEach(() => { process.env.FEATURE_NEW_CHECKOUT = 'off'; }); test('the flag is off inside the test', () => { expect(NEW_CHECKOUT).toBe(false); });npx vitest run tests/cached.test.mjs --reporter=verbose× tests/cached.test.mjs > the flag is off inside the test 7ms → expected true to be false // Object.is equality … - Expected + Received - false + true - Step 4.
Force the same module by resetting the module registry and importing again.
// tests/forced.test.mjs import { test, expect, vi, afterEach } from 'vitest'; afterEach(() => { vi.unstubAllEnvs(); }); async function loadCached(value) { vi.stubEnv('FEATURE_NEW_CHECKOUT', value); vi.resetModules(); return (await import('../src/flags-cached.mjs')).NEW_CHECKOUT; } test('the module-level flag can be forced off', async () => { expect(await loadCached('off')).toBe(false); });npx vitest run tests/forced.test.mjs --reporter=verbose✓ tests/forced.test.mjs > the module-level flag can be forced off 14ms ✓ tests/forced.test.mjs > the module-level flag can be forced on 1ms Test Files 1 passed (1) Tests 2 passed (2) - Step 5.
Assert which branch ran, on an input where both branches agree.
// tests/branch.test.mjs import { quote, branchLog } from '../src/quote.mjs'; beforeEach(() => { branchLog.length = 0; vi.stubEnv('FEATURE_NEW_CHECKOUT', 'off'); }); afterEach(() => { vi.unstubAllEnvs(); }); test('five items at 2.00 total 10.00', () => { expect(quote([{ price: 2, qty: 5 }]).total).toBe(10); }); test('the new checkout branch ran', () => { quote([{ price: 2, qty: 5 }]); expect(branchLog).toEqual(['new']); });npx vitest run tests/branch.test.mjs --reporter=verbose✓ tests/branch.test.mjs > five items at 2.00 total 10.00 4ms × tests/branch.test.mjs > the new checkout branch ran 11ms → expected [ 'legacy' ] to deeply equal [ 'new' ] - Step 6.
Run every combination of the two flags, not the one the environment happens to hold.
// tests/matrix.test.mjs const cases = [ { new_checkout: 'on', bulk_discount: 'on', expected: 99 }, { new_checkout: 'on', bulk_discount: 'off', expected: 110 }, { new_checkout: 'off', bulk_discount: 'on', expected: 99 }, { new_checkout: 'off', bulk_discount: 'off', expected: 110 }, ]; describe.each(cases)('new_checkout=$new_checkout bulk_discount=$bulk_discount', (c) => { test(`100 items at 1.10 total ${c.expected}`, () => { vi.stubEnv('FEATURE_NEW_CHECKOUT', c.new_checkout); vi.stubEnv('FEATURE_BULK_DISCOUNT', c.bulk_discount); expect(quote([{ price: 1.1, qty: 100 }]).total).toBe(c.expected); }); });npx vitest run tests/matrix.test.mjs --reporter=verbose✓ tests/matrix.test.mjs > new_checkout=on bulk_discount=on > 100 items at 1.10 total 99 4ms ✓ tests/matrix.test.mjs > new_checkout=on bulk_discount=off > 100 items at 1.10 total 110 1ms × tests/matrix.test.mjs > new_checkout=off bulk_discount=on > 100 items at 1.10 total 99 7ms → expected 110.00000000000001 to be 99 // Object.is equality × tests/matrix.test.mjs > new_checkout=off bulk_discount=off > 100 items at 1.10 total 110 1ms → expected 110.00000000000001 to be 110 // Object.is equality Test Files 1 failed (1) Tests 2 failed | 2 passed (4)
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Same summary with the flag on and off | No test reaches the flagged code | Add a case that asserts a difference, or delete the flag |
| expected true to be false on a constant | The flag was read at import, before the test set it | Reset modules and import again, or move the read into the function |
| Output assertion green, branch assertion red | The test passes through the wrong branch | Keep both assertions. The output one alone proves nothing |
| Two combinations fail out of four | The discount path exists only in the new branch | Decide whether that combination is reachable in production |
| 110.00000000000001 | The legacy branch sums floats | Expect the branch value, not a rounded one, until the flag is removed |
Step 6 is the combinatorics problem in one screen. Two boolean flags are four states, and a suite normally covers the one its config file produces. The two failures are not bugs in the test runner. They are the states nobody had run before: with the new checkout off, the discount flag has no effect, so bulk_discount=on quietly behaves like bulk_discount=off. Three flags would be eight states, and pairwise selection starts to be worth the effort somewhere around four.
The branch log in step 5 is a probe, not production code you would keep. A spy on the function each branch calls does the same job in a real codebase, and coverage of the flagged lines answers it after the fact.
Common mistakes
What to check next
- How to mock environment variables in vitest: the stubbing mechanics behind steps 4 and 6.
- How to check test isolation: a flag left stubbed is the usual cause of an order dependent failure.
- How to check config drift between environments: finds the flag default that differs between staging and production.
- How to check branch coverage: reports which side of the
ifran, after the suite finishes. - How to reset mocks between tests: the same restore discipline for spies.
FAQ
How do I test feature flags without a flag service?
Read the flag through one function, and let that function consult an environment variable before the config file. Tests then set the variable, as in step 2, and no network call or vendor SDK is involved. The same function is what production calls.
Do I need a test for every combination of flags?
Not every one. Cover each flag on and off at least once, plus any pair that interacts. Step 6 shows an interaction: the discount flag does nothing while the new checkout flag is off, so that pair needs a case and a decision about whether it can occur.
Where should the flag be forced, in the test or in the runner?
In the test, so the file states which state it covers. A variable exported in CI applies to the whole run and silently changes what every other file exercises, which is how a suite ends up testing one branch and reporting on both.
The flag is on in staging and off in production. Which does CI use?
Neither by default. Pin it per test file. A suite that inherits whatever the environment holds gives a different answer on a developer machine and in CI, and the difference appears as a flaky test rather than as a configuration problem.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny