How to mock environment variables in vitest
Set the value with vi.stubEnv('PORT', '9711') and clear it in afterEach with vi.unstubAllEnvs(). That reaches code which reads process.env on every call. A module that read the variable at import time keeps its old value, and the run below prints expected undefined to be '9711' until the module is imported again.
Why check this
Run this when a test has to exercise a code path selected by configuration: a feature flag, a provider switch, a base URL that differs between staging and production. It also belongs in the review of any suite that started failing only in CI, because a variable set by one test file and read by another is one of the two usual causes.
The failure it prevents is a green suite over broken configuration handling. The flag module below treats the string false as on, so a test that sets the flag to false and expects the feature to be off is the only test that ever catches it. Without that case, the feature ships enabled to every customer whose deployment set the flag to false.
Prerequisites
- Node 22.23.2 and npm. Every output on this page came from one Windows 11 machine on 2026-09-12.
- Vitest 5.0.0. Confirm with
npx vitest --version. The stub helpers are documented in the Vitest vi API. - A scratch project, built outside your repository so a fixture cannot reach your own suite.
mkdir env-vars-batch && cd env-vars-batch
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
- A config module with the two shapes that behave differently, and a vitest config that picks up the test files next to it.
cat > config.ts <<'EOF'
// Read once, when the module is first imported.
export const PORT = process.env.PORT;
// Read again on every call.
export function currentPort(): string | undefined {
return process.env.PORT;
}
export function checkoutEnabled(): boolean {
return Boolean(process.env.FEATURE_NEW_CHECKOUT);
}
EOF
cat > vitest.config.ts <<'EOF'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { include: ['*.test.ts'], environment: 'node' },
});
EOF
Steps
- Step 1.
Set the variable in
beforeEachand assert on both shapes.cat > load-time.test.ts <<'EOF' import { beforeEach, expect, test } from 'vitest'; import { PORT, currentPort } from './config'; beforeEach(() => { process.env.PORT = '9711'; }); test('module constant sees the value set in beforeEach', () => { expect(PORT).toBe('9711'); }); test('function sees the value set in beforeEach', () => { expect(currentPort()).toBe('9711'); }); test('PORT compares equal to the number 9711', () => { expect(currentPort()).toBe(9711); }); EOFnpx vitest run load-time.test.tsRUN v5.0.0 D:/…/env-vars-batch ❯ load-time.test.ts (3 tests | 2 failed) 11ms × module constant sees the value set in beforeEach 7ms × PORT compares equal to the number 9711 1ms FAIL load-time.test.ts > module constant sees the value set in beforeEach AssertionError: expected undefined to be '9711' // Object.is equality - Expected: "9711" + Received: undefined … FAIL load-time.test.ts > PORT compares equal to the number 9711 AssertionError: expected '9711' to be 9711 // Object.is equality - Expected: 9711 + Received: "9711" … Test Files 1 failed (1) Tests 2 failed | 1 passed (3)Two facts in one run.
importevaluatedconfig.tsbeforebeforeEachever ran, so the constant holds what the variable was at import time. And the value that did arrive is the string"9711", never the number. - Step 2.
Replace the manual assignment with
vi.stubEnvand check the flag in all three states.cat > flag.test.ts <<'EOF' import { expect, test, vi, afterEach } from 'vitest'; import { checkoutEnabled } from './config'; afterEach(() => { vi.unstubAllEnvs(); }); test('checkout is on when the flag is set', () => { vi.stubEnv('FEATURE_NEW_CHECKOUT', 'true'); expect(checkoutEnabled()).toBe(true); }); test('checkout is off when the flag is false', () => { vi.stubEnv('FEATURE_NEW_CHECKOUT', 'false'); expect(checkoutEnabled()).toBe(false); }); test('checkout is off when the flag is absent', () => { vi.stubEnv('FEATURE_NEW_CHECKOUT', undefined); expect(checkoutEnabled()).toBe(false); }); EOFnpx vitest run flag.test.ts❯ flag.test.ts (3 tests | 1 failed) 9ms × checkout is off when the flag is false 5ms FAIL flag.test.ts > checkout is off when the flag is false AssertionError: expected true to be false // Object.is equality - Expected + Received - false + true … Test Files 1 failed (1) Tests 1 failed | 2 passed (3)The first test is the one to look at. It passes, and it would pass for any non-empty string, including
false,0andoff. The second test is what the first one only appeared to prove. - Step 3.
Check what
vi.stubEnvcan reach, and what needs the module loaded again.cat > reimport.test.ts <<'EOF' import { expect, test, vi, afterEach } from 'vitest'; import { PORT } from './config'; afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); }); test('stubEnv does not change an already imported constant', () => { vi.stubEnv('PORT', '9712'); expect(PORT).toBe('9712'); }); test('resetModules plus a fresh import does', async () => { vi.stubEnv('PORT', '9712'); vi.resetModules(); const fresh = await import('./config'); expect(fresh.PORT).toBe('9712'); }); EOFnpx vitest run reimport.test.ts❯ reimport.test.ts (2 tests | 1 failed) 8ms × stubEnv does not change an already imported constant 6ms FAIL reimport.test.ts > stubEnv does not change an already imported constant AssertionError: expected undefined to be '9712' // Object.is equality - Expected: "9712" + Received: undefined … Test Files 1 failed (1) Tests 1 failed | 1 passed (2)The stub is live for every later read of
process.env. It cannot rewrite a binding that was already evaluated, so the second test is the pattern for module-level configuration. - Step 4.
Look for a variable that survives from one test file into the next.
cat > a-token.test.ts <<'EOF' import { expect, test } from 'vitest'; test('uses a token and never cleans it up', () => { process.env.API_TOKEN = 'fake-token-aaaa'; expect(process.env.API_TOKEN).toBe('fake-token-aaaa'); }); EOF cat > b-token.test.ts <<'EOF' import { expect, test } from 'vitest'; test('the suite starts with no API_TOKEN', () => { expect(process.env.API_TOKEN).toBeUndefined(); }); EOFnpx vitest run a-token b-tokenTest Files 2 passed (2) Tests 2 passed (2)npx vitest run a-token b-token --no-isolate --no-file-parallelism❯ b-token.test.ts (1 test | 1 failed) 4ms × the suite starts with no API_TOKEN 4ms FAIL b-token.test.ts > the suite starts with no API_TOKEN AssertionError: expected 'fake-token-aaaa' to be undefined - Expected: undefined + Received: "fake-token-aaaa" … Test Files 1 failed | 1 passed (2)The leak is in the code either way. Default isolation gives each file its own process and hides it. The flag that makes a large suite faster is the flag that exposes it.
- Step 5.
Check how the suite cleans up, because one common form of it does not.
node -e "process.env.CLEANUP_TEST = 'fake-value'; process.env.CLEANUP_TEST = undefined; console.log('after = undefined :', typeof process.env.CLEANUP_TEST, JSON.stringify(process.env.CLEANUP_TEST), 'truthy:', Boolean(process.env.CLEANUP_TEST)); delete process.env.CLEANUP_TEST; console.log('after delete :', typeof process.env.CLEANUP_TEST, JSON.stringify(process.env.CLEANUP_TEST));"after = undefined : string "undefined" truthy: true after delete : undefined undefinedAssigning
undefinedstores the five-character string"undefined", which is truthy. Usedelete process.env.X, orvi.unstubAllEnvs(), which restores the state captured before the first stub.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| expected undefined to be '9711' on a module constant | The module was imported before the value was set | Read inside a function, or add vi.resetModules() and a dynamic import |
| expected '9711' to be 9711 | The value arrived, as a string | Compare with a string, or parse with Number() in the module |
| A file passes alone and fails under --no-isolate | An earlier file left a variable behind | Add vi.unstubAllEnvs() in afterEach of the file that set it |
| A flag test is green for true and absent but not for false | The code uses truthiness on a string | Compare the value, === 'true', rather than coercing it |
Common mistakes
What to check next
- How to reset mocks between tests: the same cleanup question for spies and module mocks.
- How to check test isolation: the general form of the leak in step 4, for state that is not configuration.
- How to check if env variable exists: the startup guard that stops a missing variable before any test runs.
- How to check feature flags in test environment: the flag from step 2, driven through the code that reads it.
- Test environment checklist: the full pass over a test environment before sign-off.
FAQ
How do I mock environment variables in jest?
Jest has no stubEnv. Assign to process.env in beforeEach and restore a saved copy in afterEach, and use jest.resetModules() with a fresh require for module-level reads. The module-load problem and the string-typing problem are identical. Jest was not run on this machine, so no output for it appears here.
How do I stub process.env in Node without a test framework?
Assign the value, then delete process.env.X afterwards, as step 5 shows. Save and restore the whole object if the code under test writes to it. A framework helper matters mainly because it restores state on failure paths where your own afterEach may not run.
Why does one test file pass alone and fail in the whole suite?
Something ran before it and left state behind. Step 4 reproduces it for an environment variable. Run the file alone, then run it after the suspected file with --no-isolate, and the difference names the culprit.
Should I set NODE_ENV to test myself?
Vitest sets NODE_ENV to test when nothing else set it. Overriding it in a single test file changes the value for every file that shares the process under --no-isolate, so set it in the config or in the CI job rather than inside a test.
Is stubEnv needed if the code reads process.env inside a function?
No. A direct assignment works for that shape, which step 1 confirms. The helper earns its place through vi.unstubAllEnvs(), one call that restores every variable the file touched without a hand-written list.
Verified
Verified by Maks Vernyvitest 5.0.0node 22.23.2
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
intermediate8 minpublished updated Maks Verny