Isolating Environment Variables per Test
process.env is the most leaked piece of state in JavaScript test suites, because it looks like an ordinary object and behaves like a process-wide global. A test that sets process.env.FEATURE_X = 'on' and forgets to reset it changes the behaviour of every later test in the same worker, and a module that reads its configuration at import time ignores any change made after it first loaded. This guide covers stubbing variables with automatic restoration, dealing with configuration captured at module load, validating configuration in one place so tests can construct it directly, and catching the leaks that remain. It targets Vitest 2.x and applies equally to Jest with minor syntax changes, and sits under file system and process mocking.
Root Cause Analysis
Two distinct problems hide behind “environment variable leakage”, and they need different fixes. The first is mutation without restoration: a test assigns to process.env and nothing puts the original value back, so the next test inherits it. This produces classic order-dependent failures — a test passes alone and fails in the full run, or the reverse.
The second is capture at import time. A configuration module that does export const region = process.env.AWS_REGION ?? 'eu-west-1' evaluates once, when the module is first imported, and the constant never changes afterwards. A test that stubs the variable after that import sees the old value, concludes the stub did not work, and often ends up with a workaround more fragile than the original problem.
There is also a type problem underneath both. Every value in process.env is a string or undefined, so code reading it tends to parse inline — Number(process.env.PORT), process.env.DEBUG === 'true' — scattered across modules. That scattering is what makes environment-dependent code hard to test, because there is no single place to substitute a known configuration.
Reproducible Setup
Enable automatic restoration once, globally, so no individual test can forget it.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
unstubEnvs: true, // every vi.stubEnv is undone after each test
unstubGlobals: true,
env: { NODE_ENV: 'test', TZ: 'UTC' }, // baseline for the whole run
},
});
// src/config/region.ts — the capture-at-import shape, before refactoring
export const region = process.env.AWS_REGION ?? 'eu-west-1';
export const bucket = `acme-uploads-${region}`;
Implementation
Step 1 — Replace direct assignment with vi.stubEnv. It records the original value — including the case where the variable was unset — and restores it after the test.
import { test, expect, vi } from 'vitest';
import { isFeatureEnabled } from './flags';
test('enables the feature when the flag is set', () => {
vi.stubEnv('FEATURE_NEW_CHECKOUT', 'on');
expect(isFeatureEnabled('NEW_CHECKOUT')).toBe(true);
});
test('treats a missing flag as off', () => {
vi.stubEnv('FEATURE_NEW_CHECKOUT', undefined as unknown as string);
expect(isFeatureEnabled('NEW_CHECKOUT')).toBe(false);
});
Step 2 — Read configuration lazily rather than at import. A function evaluated at call time sees the current environment, which makes the capture problem disappear entirely.
// src/config/region.ts — after
export const region = () => process.env.AWS_REGION ?? 'eu-west-1';
export const bucket = () => `acme-uploads-${region()}`;
Step 3 — When the module cannot change, reset and re-import after stubbing. For third-party or legacy modules that capture at load time, stub first, then import a fresh copy.
import { test, expect, vi, beforeEach } from 'vitest';
beforeEach(() => vi.resetModules());
test('builds the bucket name from the region', async () => {
vi.stubEnv('AWS_REGION', 'us-east-2');
const { bucket } = await import('./region-legacy'); // fresh evaluation, sees the stub
expect(bucket).toBe('acme-uploads-us-east-2');
});
Step 4 — Parse and validate in one place, and let tests construct the result. A typed configuration object built from the environment gives tests a seam that does not involve the environment at all.
// src/config/index.ts
import { z } from 'zod';
const schema = z.object({
PORT: z.coerce.number().int().default(3000),
AWS_REGION: z.string().default('eu-west-1'),
FEATURE_NEW_CHECKOUT: z.enum(['on', 'off']).default('off'),
DATABASE_URL: z.string().url(),
});
export type Config = z.infer<typeof schema>;
export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => schema.parse(env);
// most tests never touch process.env — they pass a config object
import { createServer } from '../server';
test('listens on the configured port', async () => {
const server = createServer({ PORT: 0, AWS_REGION: 'eu-west-1', FEATURE_NEW_CHECKOUT: 'off', DATABASE_URL: 'postgres://x' });
// …
});
The payoff of this step is larger than it looks. Once the rest of the application receives a typed object, most tests stop needing the environment entirely: they build the configuration they want in a line, with the type checker confirming every field is present. Environment stubbing shrinks to the handful of tests that verify the loader itself, which is where it belongs, and a new variable added next month produces a compile error in every test that constructs a configuration — rather than silently defaulting in some and not others.
It also changes how configuration mistakes surface in production. Validation at start-up turns a typo in a deployment variable into an immediate, descriptive failure at boot, instead of an undefined value discovered hours later in a code path that happens to read it.
Step 5 — Test the loader once, against the real parsing rules. Defaults, coercion and validation failures are behaviour worth pinning, and they are the only tests that need to vary the environment.
// src/config/index.test.ts
import { test, expect } from 'vitest';
import { loadConfig } from './index';
test('coerces the port and applies defaults', () => {
expect(loadConfig({ PORT: '8080', DATABASE_URL: 'postgres://db/app' })).toMatchObject({
PORT: 8080, AWS_REGION: 'eu-west-1', FEATURE_NEW_CHECKOUT: 'off',
});
});
test('fails fast on a malformed database URL', () => {
expect(() => loadConfig({ DATABASE_URL: 'not a url' })).toThrow(/DATABASE_URL/);
});
Step 6 — Add a guard that detects direct mutation. A final check that the environment matches its starting state catches any test that assigned to process.env instead of stubbing.
// test/env-guard.ts — registered in setupFiles
import { beforeAll, afterAll } from 'vitest';
let snapshot: string;
beforeAll(() => { snapshot = JSON.stringify(process.env); });
afterAll(() => {
if (JSON.stringify(process.env) !== snapshot) {
throw new Error('process.env was mutated without vi.stubEnv in this file');
}
});
Where a whole group of tests needs the same baseline — a feature flag on for an entire file, say — stub it in a beforeEach rather than repeating the call in every test. Restoration still happens after each test, so the next one starts from a clean state and re-applies the stub; nothing about the file’s setup can leak into its neighbours.
Verification
Run the suite in shuffled order several times. Environment leaks are order-dependent by nature, so shuffling is the fastest way to expose any that remain.
for seed in 1 2 3 4 5 6; do
npx vitest run --sequence.shuffle --sequence.seed=$seed --silent || echo "seed $seed failed"
done
# (no output)
Then confirm the guard fires by adding a deliberate direct assignment to one test and running that file. The guard should fail the file with its message, proving it would catch a real leak.
npx vitest run src/config/flags.test.ts
# Error: process.env was mutated without vi.stubEnv in this file
Troubleshooting
Symptom: vi.stubEnv appears to do nothing. Diagnosis: the value was captured at import time. Fix: read lazily as in Step 2, or reset modules and import after stubbing as in Step 3 — and confirm by logging the value inside the function under test rather than in the test body.
Symptom: a variable stubbed to undefined still reads as the string "undefined". Diagnosis: assigning undefined to process.env coerces it to a string in Node. Fix: use vi.stubEnv with undefined, which deletes the key rather than assigning, or delete it explicitly.
Symptom: tests pass locally and fail in CI. Diagnosis: a variable is set in the developer’s shell or .env file and absent in CI, and the code has no default. Fix: declare every variable the tests depend on in the Vitest env block, so local and CI runs start from the same baseline.
Symptom: the guard fires in a file that never touches process.env. Diagnosis: a library the file imports mutates the environment — some SDKs set variables on load. Fix: stub the variable the library sets before importing it, or exclude that key from the guard’s comparison with a comment explaining why.
FAQ
Is vi.stubEnv safe with parallel tests?
Yes, within the usual rules. Each worker is a separate process or thread with its own process.env view, so stubs in one worker do not affect another. Within a worker, tests run sequentially and restoration happens between them, which is exactly the granularity that matters.
Should I load a .env.test file?
It is convenient for values the whole suite shares, such as a test database URL. Keep it small and committed, so it is identical everywhere, and prefer the Vitest env block for anything tests depend on — values hidden in a dotfile are easy to overlook when a CI run behaves differently.
What about import.meta.env in Vite projects?
Vitest supports stubbing it the same way; vi.stubEnv updates both process.env and import.meta.env. The capture-at-import problem applies identically, so the lazy-read and single-loader patterns carry over unchanged.
How does this relate to feature flags?
Environment variables are the simplest flag mechanism, and everything here applies to them. Once flags come from a provider rather than the environment, the seam moves to the provider client — covered in mocking feature flag providers deterministically.
Related
- Back to File System & Process Mocking
- Eliminating test order dependence — environment leaks are one of the four common causes.
- Resetting the module registry between tests — the mechanics behind Step 3.
- Asserting on stdout and process exit codes — the other process-global surface.