Testing Your Environment Variables with CtroEnv
Testing Your Environment Variables with CtroEnv
Environment variables are global state. Testing them usually means mutating process.env and praying you clean up after. CtroEnv's objectSource() gives you a better way.
The Problem
A typical test for env validation looks like this:
beforeEach(() => {
process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"
process.env.JWT_SECRET = "my-test-secret-that-is-long-enough"
})
afterEach(() => {
delete process.env.DATABASE_URL
delete process.env.JWT_SECRET
})
This is fragile. One test forgets to clean up and every other test inherits the pollution. Parallel tests become impossible.
objectSource() to the Rescue
objectSource() wraps a plain object as an EnvSource — no globals involved:
import { describe, expect, it } from "vitest"
import { defineEnv, objectSource, string, number } from "@ctroenv/core"
function makeEnv(overrides: Record<string, string> = {}) {
return defineEnv(
{
DATABASE_URL: string().url(),
PORT: number().port().default(3000),
JWT_SECRET: string().min(32).secret(),
},
{ source: objectSource(overrides) },
)
}
it("parses valid env vars", () => {
const env = makeEnv({
DATABASE_URL: "postgresql://localhost:5432/db",
JWT_SECRET: "a".repeat(32),
})
expect(env.DATABASE_URL).toBe("postgresql://localhost:5432/db")
expect(env.PORT).toBe(3000) // default
})
it("applies defaults when vars are missing", () => {
const env = makeEnv({
DATABASE_URL: "postgresql://localhost:5432/db",
JWT_SECRET: "a".repeat(32),
})
expect(env.PORT).toBe(3000)
})
it("throws on missing required vars", () => {
expect(() => makeEnv({ PORT: "4000" })).toThrow()
})
No global mutation. No cleanup. Every test gets a fresh environment.
Testing Validation Errors
Test specific error conditions with CtroEnvError:
import { CtroEnvError, formatErrors } from "@ctroenv/core"
it("reports invalid URL", () => {
try {
makeEnv({
DATABASE_URL: "not-a-url",
JWT_SECRET: "a".repeat(32),
})
} catch (e) {
expect(e).toBeInstanceOf(CtroEnvError)
expect((e as CtroEnvError).errors).toHaveLength(1)
expect((e as CtroEnvError).errors[0].key).toBe("DATABASE_URL")
expect((e as CtroEnvError).errors[0].code).toBe("invalid_value")
}
})
it("reports multiple errors", () => {
try {
makeEnv({}) // both DATABASE_URL and JWT_SECRET missing
} catch (e) {
expect(e).toBeInstanceOf(CtroEnvError)
expect((e as CtroEnvError).errors).toHaveLength(2)
}
})
Testing Secret Masking
Secret values are masked in the proxy. Access raw values via meta.get():
it("masks secret values", () => {
const env = makeEnv({
DATABASE_URL: "postgresql://localhost:5432/db",
JWT_SECRET: "my-real-secret-".padEnd(32, "x"),
})
expect(env.JWT_SECRET).toBe("********")
expect(env.meta.get("JWT_SECRET")).toBe("my-real-secret-".padEnd(32, "x"))
})
it("supports custom mask", () => {
const env = defineEnv(
{
KEY: string().secret(),
},
{ source: objectSource({ KEY: "value" }), maskWith: "***" },
)
expect(env.KEY).toBe("***")
})
Testing Different Environments
Use helper functions to model different scenarios:
function devEnv() {
return makeEnv({
DATABASE_URL: "postgresql://localhost:5432/dev",
JWT_SECRET: "dev-secret-not-long-enough-but-ok-for-dev",
})
}
function prodEnv() {
return makeEnv({
DATABASE_URL: "postgresql://prod.internal:5432/prod",
JWT_SECRET: "a".repeat(64),
})
}
it("dev uses local database", () => {
expect(devEnv().DATABASE_URL).toContain("localhost")
})
it("prod uses internal host", () => {
expect(prodEnv().DATABASE_URL).toContain("prod.internal")
})
Integration with Vitest
CtroEnv's schema definitions are just objects. Import and reuse them across test files:
// env.ts
export const schema = {
DATABASE_URL: string().url(),
PORT: number().port().default(3000),
} as const
// env.test.ts
import { schema } from "./env"
it("validates correctly", () => {
const env = defineEnv(schema, { source: objectSource({ DATABASE_URL: "http://localhost:5432/db" }) })
expect(env.DATABASE_URL).toBeDefined()
})
No singletons. No global state. Just testable, typed environment access.