Testing Your Environment Variables with CtroEnv

2026-06-18·Ctrotech·
guidetesting

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.