obsidian/wiki/concepts/vitest-module-level-env-testing.md
2026-05-10 22:37:43 +01:00

3.5 KiB

name description type
vitest-module-level-env-testing vi.stubEnv + vi.resetModules() + dynamic await import() required when testing modules that read env vars at load time concept

Vitest — Testing Modules That Read Env Vars at Import Time

The Problem

Many Next.js route files or Node.js modules read process.env variables at module load time:

// src/app/api/binotel/webhook/route.ts
const SECRET = process.env.BINOTEL_HMAC_SECRET ?? ''
//                                               ^^^
// Read ONCE when the module is imported — not on each request

A standard Vitest test that sets process.env.BINOTEL_HMAC_SECRET = '' after importing the module does not affect the SECRET constant — it was already captured.

// BAD — env var set after module is already cached
import { POST } from '../route'

test('rejects empty secret', async () => {
  process.env.BINOTEL_HMAC_SECRET = ''  // too late — module already imported
  const res = await POST(makeRequest())
  expect(res.status).toBe(500)  // might not behave as expected
})

The Fix — vi.stubEnv + vi.resetModules() + Dynamic Import

import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'

describe('webhook route — empty secret', () => {
  beforeEach(() => {
    vi.stubEnv('BINOTEL_HMAC_SECRET', '')   // 1. Set env var before import
    vi.resetModules()                         // 2. Clear module cache
  })

  afterEach(() => {
    vi.unstubAllEnvs()                        // 3. Restore env vars after test
    vi.resetModules()                          // 4. Clean slate for next test
  })

  it('returns 500 when secret is empty', async () => {
    // 5. Dynamic import AFTER stubEnv + resetModules
    const { POST } = await import('../route')
    const res = await POST(makeRequest())
    expect(res.status).toBe(500)
  })
})

Why This Works

Step Effect
vi.stubEnv('KEY', 'value') Sets process.env.KEY and records it for later restoration
vi.resetModules() Clears Vitest's module registry — next import() re-executes the module
await import('../route') Module re-executes with new process.env values, captures updated constants
vi.unstubAllEnvs() Restores process.env to original values

Testing timingSafeEqual Length Mismatch

A related gotcha: crypto.timingSafeEqual(a, b) throws a RangeError if buffers have different lengths. Most implementations add a length-mismatch fast-path:

function safeCompare(a: string, b: string): boolean {
  if (a.length !== b.length) return false  // ← must be tested explicitly
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
}

Test the fast-path separately:

it('returns false for different-length strings without throwing', () => {
  expect(safeCompare('abc', 'abcdef')).toBe(false)
  // Would throw RangeError if fast-path is missing
})

ESM Module Testing

If the module under test is pure ESM (has "type": "module" and uses import/export without CommonJS), it cannot be require()-d in a test. The dynamic await import() pattern works because Vitest runs in ESM mode itself.

If a module uses top-level await at import time (e.g., connects to DB), resetModules() + re-import will re-run that connection on every test — use vi.mock to stub the dependency instead.

Pattern Summary

stubEnv → resetModules → await import() → test → unstubAllEnvs → resetModules

Source: daily/2026-05-09.md | 2026-05-09