99 lines
3.5 KiB
Markdown
99 lines
3.5 KiB
Markdown
---
|
|
name: vitest-module-level-env-testing
|
|
description: vi.stubEnv + vi.resetModules() + dynamic await import() required when testing modules that read env vars at load time
|
|
type: 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:
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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
|