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.

Two different environment-variable problems Mutation without restoration leaks a value into later tests, while capture at import time ignores a stub made after the module first loaded; the first is fixed by stubEnv, the second by reading configuration lazily. mutation leaks forward test A sets FEATURE_X=on test B still sees on fix: vi.stubEnv + unstubEnvs order-dependent failures capture ignores stubs module reads env on import later stubs have no effect fix: read lazily, or re-import "my stub did nothing"
The same symptom — the wrong value in a test — has two causes with opposite fixes.

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' });
  // …
});
One parsing seam instead of scattered reads Scattered process.env reads across many modules each need their own stub, while a single loadConfig function parses the environment once into a typed object that tests can construct directly, with only the loader itself tested against the real environment. Scattered reads server.ts reads PORT upload.ts reads REGION flags.ts reads FEATURE_* stub each one One seam process.env strings loadConfig() parse, validate, type Config object tests build it directly
With one seam, only the loader's own tests need to touch the environment at all.

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
Three layers of defence against environment leaks Automatic unstubbing restores stubbed values, lazy configuration reading makes stubs effective, and a per-file guard fails loudly on any direct mutation that bypassed the other two. unstubEnvs stubbed values restored after every test lazy config stubs take effect whenever they are made mutation guard catches the direct assignment that slipped in
Each layer covers a gap the others leave; together they make environment leakage a loud failure rather than a flaky one.

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.