Testing
Give each vitest test its own config instead of your machine's, then restore the real source after each test.
Without a source of your own, a test that reads config reads the machine it runs on. On Node, envapt reads a copy of process.env from FileSource and loads the .env files in the working directory, .env.local included. Your laptop has a .env.local and CI doesn't, so the same test can pass in one place and fail in the other.
If you assign process.env.PORT = '4000' inside the test, nothing changes until the cache rebuilds. FileSource takes its copy when the cache is built, so a later assignment isn't in it (see When values are read).
To give a test its own values, bind a source with Envapter.useSource. It rebuilds the cache as soon as you call it, so the next read uses your values. The cost is that the binding is process-wide. Tests that run concurrently in one file share it, so run these sequentially.
The examples use vitest. In another runner that loads ESM, such as bun test, the envapt calls stay the same and only the hook and stub names change.
Where a test's values come from
Bind a PortableSource built from a plain object. You control every key, so no shell variable or .env file reaches envapt.
import { , , } from 'vitest';
import { , , , } from 'envapt';
import { } from './config';
(() => {
.(new ());
});
('converts the values it is given', () => {
.(
new ({
: 'test',
: 'postgres://app@localhost:5432/app',
: '4000',
: '30s'
})
);
(().).(30_000);
(.).(true);
});
('stops when a value is missing', () => {
.(new ({}));
(() => ()).(.({ : . }));
});loadConfig is getRequiredAll over the three keys in config.ts. vitest sets NODE_ENV=test, but the fixture is the only thing envapt reads now. Without ENVIRONMENT: 'test', Envapter.environment would be 'development' and the isTest check in the first test would fail. The second test binds an empty fixture to check the MissingEnvValue failure that Required values describes. After each test, afterEach binds a fresh FileSource, which reads process.env and the files again.
Put the real config back
If you leave a source bound, it stays bound for the later tests in the same file. vitest runs each test file in isolation by default, so a bound source doesn't carry over to the next file. These settings keep their value after useSource, so each one you change needs its own line in your cleanup.
| You changed | Undo it with | Note |
|---|---|---|
Envapter.useSource(source) | Envapter.useSource(new FileSource()) | rebuilds the cache from process.env and the files |
Envapter.environment = '...' | Envapter.resetPaths() | the pin survives useSource. resetPaths also clears envPaths and profiles |
Envapter.baseDir = '...' | Envapter.baseDir = undefined | resetPaths keeps baseDir, and a file source has to be bound first |
Envapter.strict = true | Envapter.strict = false | useSource leaves it as it was |
Envapter.envFileOptions = {...} | Envapter.envFileOptions = {} | resetPaths keeps it |
Envapter.debug = 'verbose' | Envapter.debug = 'silent' | useSource leaves it as it was |
Envapter.fileApiMode = 'throw' | Envapter.fileApiMode = 'warn' | useSource leaves it as it was |
Leave Envapter.syncProcessEnv off in tests, which is its default. When it's on, envapt writes the keys it loaded from .env files into process.env. Turning it off again doesn't remove keys already written, so they stay in process.env until the process exits.
Put the cleanup lines you use in one afterEach inside a setup file, so no test file repeats them. vitest runs a setup file's hooks around every test in every file.
import { afterEach } from 'vitest';
import { Envapter, FileSource } from 'envapt';
afterEach(() => {
Envapter.useSource(new FileSource());
Envapter.resetPaths();
Envapter.baseDir = undefined;
Envapter.strict = false;
});useSource comes first because baseDir throws FileApiUnsupported unless a file source is bound. List the file under setupFiles in your vitest config:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { setupFiles: ['./tests/setup.ts'] }
});tests/setup.ts imports FileSource, so it can't run on a Workers or browser test runner. Those runners resolve the portable build, which exports no FileSource. Restore by binding the platform's object again, such as new PortableSource(env) in a Worker test (Cloudflare Workers) or new PortableSource(import.meta.env) in a browser test (Browser). On the portable build, resetPaths, baseDir, envPaths, envFileOptions, and configureProfiles warn and do nothing under the default fileApiMode of 'warn', and throw FileApiUnsupported under 'throw'. A pinned environment therefore stays pinned. Put ENVIRONMENT in the fixture there and skip the pin.
With isolate turned off
If you set vitest's isolate: false, test files that run in one worker share their modules, including envapt and its state. A source, a strict setting, or a pinned environment set in one file is still there when the next file starts. If a setup file calls vi.resetModules(), each file gets fresh modules, but only for modules that vitest loads itself. Envapt isn't one of those, because Node loads packages from node_modules without passing them through vitest. List envapt under server.deps.inline so vitest loads it:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
isolate: false,
setupFiles: ['./tests/fresh-modules.ts', './tests/setup.ts'],
server: { deps: { inline: ['envapt'] } }
}
});import { vi } from 'vitest';
vi.resetModules();fresh-modules.ts comes first in setupFiles. If setup.ts ran first, it would bind its source on a copy of envapt that the reset then discards. The test file would import a fresh copy, which binds FileSource on import and reads your machine's values.
Reads at module scope run before the test
If you read a value outside a function, the read runs when its module first loads, before any beforeEach. A fixture bound later can't change it.
export const = .('PORT', ., 3000);
export function (): number {
return .('PORT', ., 3000);
}Under vitest, port holds whatever the real source gave when the module loaded, so every test reads that same number. getPort() reads when a test calls it. If a test needs to vary a constant, turn it into a function like getPort.
If no test varies it, module scope is fine, as long as the values exist when the module loads. Required values recommends calling getRequiredAll at the top level of a config module. That module reads when the test file imports it. Bind the test values in a setup file, because vitest runs setupFiles before it imports each test file:
import { afterEach } from 'vitest';
import { Envapter, PortableSource } from 'envapt';
const testEnv = new PortableSource({
ENVIRONMENT: 'test',
DATABASE_URL: 'postgres://app@localhost:5432/app',
PORT: '4000',
CACHE_TTL: '30s'
});
Envapter.useSource(testEnv);
afterEach(() => {
Envapter.useSource(testEnv);
Envapter.strict = false;
});The config module reads testEnv once, when the test file imports it, so every test in that file gets the same values. This tests/setup.ts replaces the one in Put the real config back. Its afterEach binds testEnv again, because binding FileSource there would make the next test read your machine's values. It leaves out the baseDir line because baseDir throws FileApiUnsupported while a PortableSource is bound, so a test that pins environment has to call Envapter.resetPaths() itself.
Decorated classes
A decorated field throws EnvaptError with code InvalidUserDefinedConfig (302) when you assign to it, so a test binds a source to change it. A decorated field stores the first value it reads. useSource clears that stored value, so the next read of the field converts the value from the new source. If you leave a source bound, a field you read in one test keeps that value in the tests after it. These samples use the envapt/legacy decorators, because vitest's default transform can't load the modern ones (the error below).
// config.ts
import { Converters } from 'envapt';
import { Envapt } from 'envapt/legacy';
export class Config {
@Envapt('CACHE_TTL', { converter: Converters.Time, fallback: '5m' })
static readonly cacheTtl: number;
}// config.test.ts
import { afterEach, expect, it } from 'vitest';
import { Envapter, FileSource, PortableSource } from 'envapt';
import { Config } from './config';
afterEach(() => {
Envapter.useSource(new FileSource());
});
it('reads the field again after the source changes', () => {
Envapter.useSource(new PortableSource({ CACHE_TTL: '30s' }));
expect(Config.cacheTtl).toBe(30_000);
Envapter.useSource(new PortableSource({ CACHE_TTL: '1m' }));
expect(Config.cacheTtl).toBe(60_000);
});The second Config.cacheTtl read converts '1m' because the second useSource call cleared the first value. afterEach binds FileSource again, so the next test reads Config.cacheTtl from your real config.
SyntaxError: Invalid or unexpected token
With vitest's default transform, if a test file imports a class that uses @Envapt on a static accessor field, the file fails to load with this error. Node can't parse the decorator syntax, and vitest leaves it unchanged. The envapt/legacy decorators load when experimentalDecorators is on in tsconfig.json. They import from envapt/legacy and declare fields as static readonly (Decorators). Bun (bun test included) and Deno run the modern form directly (Compatibility).
Secret stores
Read secrets from your secrets manager as typed values, either through a wrapper CLI or by fetching them at boot and binding them next to your .env files.
Compatibility
Look up the Node, Bun, and Deno versions envapt needs, which build your runtime loads, and the TypeScript settings that let you import it.