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

mkdir env-vars-batch && cd env-vars-batch
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
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

  1. Step 1.

    Set the variable in beforeEach and 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);
    });
    EOF
    
    npx vitest run load-time.test.ts
    
     RUN  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. import evaluated config.ts before beforeEach ever 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.

  2. Step 2.

    Replace the manual assignment with vi.stubEnv and 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);
    });
    EOF
    
    npx 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, 0 and off. The second test is what the first one only appeared to prove.

  3. Step 3.

    Check what vi.stubEnv can 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');
    });
    EOF
    
    npx 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.

  4. 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();
    });
    EOF
    
    npx vitest run a-token b-token
    
     Test 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.

  5. 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 undefined

    Assigning undefined stores the five-character string "undefined", which is truthy. Use delete process.env.X, or vi.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

Sign: vi.stubEnv runs before the assertion and the value never changes.Cause: The value under test is a module-level constant that was evaluated at import time. The stub changes process.env, not a binding already computed from it. Only a fresh import after vi.resetModules picks up the new value.
Sign: A feature flag set to the string false turns the feature on.Cause: Every value in process.env is a string, and Boolean('false') is true. A guard written as Boolean(process.env.FLAG) or as an if on the raw value passes for 'false', '0' and 'off' alike.
Sign: The suite is green locally and in CI, then breaks the week someone turns on a speed flag.Cause: Vitest isolates each file in its own process by default, so a variable left behind never reaches the next file. Running with --no-isolate reuses one process and the leak becomes a failure in a file that did not change.
Sign: Cleanup sets the variable back to undefined and the next test still sees a value.Cause: process.env coerces whatever it is given to a string, so the assignment stores 'undefined'. That string is truthy, so a guard on the variable now passes when it should not. delete removes the key.

What to check next

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.

intermediate8 minpublished updated Maks Verny