diff --git a/README.md b/README.md index 5a48c4b..32e37f5 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ Each validation function accepts an (optional) object with the following attribu - `devDefault` - A fallback value to use _only_ when `NODE_ENV` is explicitly set and _not_ `'production'`. This is handy for env vars that are required for production environments, but optional for development and testing. +- `testDefault` - A fallback value to use _only_ when `NODE_ENV=test`. When provided, it takes + priority over both `devDefault` and `default` in test environments. Unlike the `testOnly()` + helper (which is wrapped around a `devDefault` and therefore can't coexist with a separate + dev value), `testDefault` can be combined with `default` and `devDefault` to provide + distinct values for production, development, and test. - `desc` - A string that describes the env var. - `example` - An example value for the env var. - `docs` - A URL that leads to more detailed documentation about the env var. @@ -220,7 +225,7 @@ argument required in the third position: ### testOnly -The `testOnly` helper function is available for setting a default value for an env var only when `NODE_ENV=test`. It is recommended to use this function along with `devDefault`. For example: +The `testOnly` helper function is available for setting a default value for an env var only when `NODE_ENV=test`. It is used by wrapping a `devDefault` value: ```js const env = cleanEnv(process.env, { @@ -228,6 +233,18 @@ const env = cleanEnv(process.env, { }) ``` +Because `testOnly` is applied via `devDefault`, you cannot use it to express both a development default _and_ a separate test default. For that, prefer the `testDefault` spec attribute, which can be combined freely with `default` and `devDefault`: + +```js +const env = cleanEnv(process.env, { + SOME_VAR: envalid.str({ + default: 'productionValue', + devDefault: 'devValue', + testDefault: 'myTestValue', + }), +}) +``` + For more context see [this issue](https://github.com/af/envalid/issues/32). ## FAQ diff --git a/src/core.ts b/src/core.ts index 214e28c..f06a6b5 100644 --- a/src/core.ts +++ b/src/core.ts @@ -67,9 +67,15 @@ export function getSanitizedEnv( const rawValue = readRawEnvValue(environment, k) try { - // If no value was given and default/devDefault were provided, return the appropriate default - // value without passing it through validation + // If no value was given and default/devDefault/testDefault were provided, return the + // appropriate default value without passing it through validation if (rawValue === undefined) { + // Use testDefault only when NODE_ENV is 'test'. Takes priority over devDefault and default. + if (rawNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { + cleanedEnv[k] = spec.testDefault + continue + } + // Use devDefault values only if NODE_ENV was explicitly set, and isn't 'production' const usingDevDefault = rawNodeEnv && rawNodeEnv !== 'production' && Object.hasOwn(spec, 'devDefault') diff --git a/src/types.ts b/src/types.ts index 50e62b6..e4e697a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,12 @@ export interface Spec { * This is handy for env vars that are required for production environments, but optional for development and testing. */ devDefault?: NonNullable | undefined + /** + * A fallback value to use only when NODE_ENV is 'test'. Takes priority over `devDefault` and `default`. + * Unlike the `testOnly()` helper (which wraps a `devDefault` value), `testDefault` can be combined + * with both `default` and `devDefault` to specify distinct values for production, development, and test. + */ + testDefault?: NonNullable | undefined /** * A function (env -> boolean) that allows an env var to be required only when certain @@ -35,16 +41,31 @@ export interface Spec { type OptionalAttrs = | { default: undefined } | { devDefault: undefined } + | { testDefault: undefined } | { default: undefined; devDefault: undefined } + | { default: undefined; testDefault: undefined } + | { devDefault: undefined; testDefault: undefined } + | { default: undefined; devDefault: undefined; testDefault: undefined } | { default: NonNullable; devDefault: undefined } + | { default: NonNullable; testDefault: undefined } + | { devDefault: NonNullable; testDefault: undefined } | { default: undefined; devDefault: NonNullable } + | { default: undefined; testDefault: NonNullable } + | { devDefault: undefined; testDefault: NonNullable } + | { default: NonNullable; devDefault: NonNullable; testDefault: undefined } + | { default: NonNullable; devDefault: undefined; testDefault: NonNullable } + | { default: undefined; devDefault: NonNullable; testDefault: NonNullable } type RequiredAttrs = | { default: NonNullable } | { devDefault: NonNullable } + | { testDefault: NonNullable } | { devDefault: NonNullable; default: NonNullable } + | { testDefault: NonNullable; default: NonNullable } + | { testDefault: NonNullable; devDefault: NonNullable } + | { testDefault: NonNullable; devDefault: NonNullable; default: NonNullable } | {} -type DefaultKeys = 'default' | 'devDefault' +type DefaultKeys = 'default' | 'devDefault' | 'testDefault' type OptionalSpec = Spec & OptionalAttrs type OptionalTypelessSpec = Omit, 'choices'> diff --git a/tests/basics.test.ts b/tests/basics.test.ts index c96548f..c5d23bf 100644 --- a/tests/basics.test.ts +++ b/tests/basics.test.ts @@ -89,6 +89,79 @@ test('falsy devDefault', () => { expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow() }) +test('testDefault', () => { + const spec = { + FOO: str({ testDefault: 'hi' }), + } + + // testDefault is used when NODE_ENV is 'test' + const env = cleanEnv({ NODE_ENV: 'test' }, spec) + expect(env).toEqual({ FOO: 'hi' }) + + // testDefault is not used in production (and the field has no other default, so it throws) + expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow() + + // testDefault is not used in development either + expect(() => cleanEnv({ NODE_ENV: 'development' }, spec, makeSilent)).toThrow() +}) + +test('testDefault set to undefined', () => { + const env = cleanEnv( + { NODE_ENV: 'test' }, + { + FOO: str({ testDefault: undefined }), + }, + ) + expect(env).toEqual({ FOO: undefined }) +}) + +test('falsy testDefault', () => { + const spec = { + FOO: str({ testDefault: '' }), + } + + const env = cleanEnv({ NODE_ENV: 'test' }, spec) + expect(env).toEqual({ FOO: '' }) + + expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow() +}) + +test('testDefault takes priority over devDefault and default in test', () => { + const spec = { + FOO: str({ default: 'prod', devDefault: 'dev', testDefault: 'test' }), + } + + expect(cleanEnv({ NODE_ENV: 'test' }, spec)).toEqual({ FOO: 'test' }) + expect(cleanEnv({ NODE_ENV: 'development' }, spec)).toEqual({ FOO: 'dev' }) + expect(cleanEnv({ NODE_ENV: 'production' }, spec)).toEqual({ FOO: 'prod' }) +}) + +test('testDefault falls back to devDefault/default outside of test', () => { + // testDefault alongside default: outside test, uses default + expect( + cleanEnv( + { NODE_ENV: 'production' }, + { FOO: str({ default: 'prod', testDefault: 'test' }) }, + ), + ).toEqual({ FOO: 'prod' }) + + // testDefault alongside devDefault: in development, uses devDefault + expect( + cleanEnv( + { NODE_ENV: 'development' }, + { FOO: str({ devDefault: 'dev', testDefault: 'test' }) }, + ), + ).toEqual({ FOO: 'dev' }) +}) + +test('testDefault overrides default in test even when devDefault absent', () => { + const env = cleanEnv( + { NODE_ENV: 'test' }, + { FOO: str({ default: 'prod', testDefault: 'test' }) }, + ) + expect(env).toEqual({ FOO: 'test' }) +}) + test('devDefault and default together', () => { const spec = { FOO: num({ devDefault: 3000, default: 80 }), diff --git a/tests/types.test.ts b/tests/types.test.ts index a11ffa6..62299fc 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -32,6 +32,10 @@ describe('validators types', () => { OptionalValidatorSpec >() expectTypeOf(validator({ devDefault: false })).toEqualTypeOf>() + expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf< + OptionalValidatorSpec + >() + expectTypeOf(validator({ testDefault: false })).toEqualTypeOf>() }) test('number-based validators', () => { @@ -90,6 +94,12 @@ describe('validators types', () => { OptionalValidatorSpec >() expectTypeOf(validator<2>({ devDefault: 2 })).toEqualTypeOf>() + expectTypeOf(validator({ testDefault: 0 })).toEqualTypeOf>() + expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf< + OptionalValidatorSpec + >() + // @ts-expect-error - 3 is not assignable to 1 | 2 + validator({ choices: [1, 2], testDefault: 3 }) }) test('string-based validators', () => { const validator = makeValidator(() => '') @@ -161,6 +171,20 @@ describe('validators types', () => { expectTypeOf( validator<'foo' | 'bar'>({ choices: ['foo', 'bar'], devDefault: 'bar' }), ).toEqualTypeOf>() + expectTypeOf(validator({ testDefault: 'foo' })).toEqualTypeOf>() + expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf< + OptionalValidatorSpec + >() + expectTypeOf( + validator({ default: 'foo', devDefault: 'foo', testDefault: 'foo' }), + ).toEqualTypeOf>() + expectTypeOf( + validator<'foo' | 'bar' | 'baz'>({ + default: 'foo', + devDefault: 'bar', + testDefault: 'baz', + }), + ).toEqualTypeOf>() }) test('structured data validator', () => { const validator = makeStructuredValidator(() => ({})) @@ -215,6 +239,8 @@ test('cleanEnv', () => { STR: 'FOO', STR_OPT: undefined, STR_DEV_DEFAULT_UDEF: undefined, + STR_TEST_DEFAULT: 'value', + STR_TEST_DEFAULT_UDEF: 'value', STR_CHOICES: 'foo', STR_REQ: 'BAR', STR_DEFAULT_CHOICES: 'bar', @@ -239,6 +265,8 @@ test('cleanEnv', () => { STR_CHOICES: str({ choices: ['foo', 'bar'] }), STR_REQ: str({ default: 'foo' }), STR_DEFAULT_CHOICES: str({ default: 'foo', choices: ['foo', 'bar'] }), + STR_TEST_DEFAULT: str({ testDefault: 'foo' }), + STR_TEST_DEFAULT_UDEF: str({ testDefault: undefined }), BOOL: bool(), BOOL_OPT: bool({ default: undefined }), BOOL_DEV_DEFAULT: bool({ devDefault: undefined }), @@ -261,6 +289,8 @@ test('cleanEnv', () => { STR_CHOICES: 'foo' | 'bar' STR_REQ: string STR_DEFAULT_CHOICES: 'foo' | 'bar' + STR_TEST_DEFAULT: string + STR_TEST_DEFAULT_UDEF: string | undefined BOOL: boolean BOOL_OPT: boolean | undefined BOOL_DEFAULT: boolean @@ -286,6 +316,8 @@ test('cleanEnv', () => { STR_CHOICES: str({ choices: ['foo', 'bar'] }), STR_REQ: str({ default: 'foo' }), STR_DEFAULT_CHOICES: str({ default: 'foo', choices: ['foo', 'bar'] }), + STR_TEST_DEFAULT: str({ testDefault: 'foo' }), + STR_TEST_DEFAULT_UDEF: str({ testDefault: undefined }), BOOL: bool(), BOOL_OPT: bool({ default: undefined }), BOOL_DEV_DEFAULT: bool({ devDefault: undefined }),