Skip to content

Testing

A configuration that reads process.env and the filesystem at import time is awkward to test. Layerfig gives you two options that make it hermetic: runtimeEnv decides where environment values come from, and ObjectSource removes the need for files.

runtimeEnv accepts any plain object, so a test can supply exactly the variables under test — no mutation of process.env, no leakage between test cases:

import { ConfigBuilder, FileSource } from "@layerfig/config";
import { schema } from "./schema";

export function buildConfig(runtimeEnv: Record<string, string | undefined>) {
  return new ConfigBuilder({
    validate: (finalConfig) => schema.parse(finalConfig),
    runtimeEnv,
  })
    .addSource(new FileSource("base.json"))
    .build();
}

// production code
export const config = buildConfig(process.env);
import { expect, test } from "vitest";
import { buildConfig } from "./config";

test("PORT overrides the default", () => {
  expect(buildConfig({ PORT: "8080" }).port).toBe(8080);
});

test("falls back when PORT is unset", () => {
  expect(buildConfig({}).port).toBe(3000);
});

Swap FileSource for ObjectSource and the test needs no fixture files at all. Because both are plain sources, the layering behaves identically:

import { ConfigBuilder, ObjectSource } from "@layerfig/config";
import { schema } from "./schema";

const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
  runtimeEnv: { PORT: "8080" },
})
  .addSource(new ObjectSource({ appURL: "http://localhost:${PORT}", port: "${PORT}" }))
  .build();

expect(config.appURL).toBe("http://localhost:8080");

This is the fastest way to unit-test a slot expression or a schema rule in isolation.

To assert that your committed configuration is valid — a useful test in itself — point absoluteConfigFolderPath at the real folder and let it throw:

import path from "node:path";
import { expect, test } from "vitest";
import { ConfigBuilder, FileSource } from "@layerfig/config";
import { schema } from "./schema";

test.each(["local", "staging", "prod"])("%s config is valid", (env) => {
  expect(() =>
    new ConfigBuilder({
      validate: (finalConfig) => schema.parse(finalConfig),
      absoluteConfigFolderPath: path.resolve(import.meta.dirname, "../config"),
      runtimeEnv: { APP_VERSION: "test", DATABASE_URL: "postgres://test" },
    })
      .addSource(new FileSource("base.json"))
      .addSource(new FileSource(`${env}.json`))
      .build()
  ).not.toThrow();
});

Supplying runtimeEnv explicitly matters here: it means the test fails when a config file references a variable nobody remembered to document, instead of quietly passing on whatever happens to be in the developer’s shell.

The client builder takes the same runtimeEnv, so the identical pattern works — pass an object instead of import.meta.env:

import { ConfigBuilder, ObjectSource } from "@layerfig/config/client";

const config = new ConfigBuilder({
  validate: (finalConfig, z) =>
    z.object({ appVersion: z.string() }).parse(finalConfig),
  runtimeEnv: { PUBLIC_APP_VERSION: "1f550b7" },
})
  .addSource(new ObjectSource({ appVersion: "${PUBLIC_APP_VERSION}" }))
  .build();