DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Three-Layer Config Merge Before You Extract One Coercer

Messy config loaders hide merge bugs. Extract nothing until merge order is pinned.

A characterization test freezes env, file, and default layers. The smallest safe change then touches one coercer only.

The failure this workflow targets

Many modules load settings from three places. Defaults sit in source. A YAML or JSON file overlays them. Environment variables are supposed to win last.

Comments often disagree with the actual merge. A later extract of load_config() silently swaps layers. Smoke tests still pass. Production then reads the wrong timeout.

This workflow pins the merged dict first. It does not redesign your config system.

What you pin, and what you skip

Pin three facts. Skip everything else on the first pass.

  1. Precedence for every key under test.
  2. Coercion for bools, ints, and empty strings.
  3. Whether os.environ mutates during load.

Do not pin wall-clock timestamps. Do not pin raw temp paths. Normalize those values before the snapshot.

Do not dump live secrets into fixtures. Use synthetic keys only.

Artifact: a frozen merge snapshot

The harness below is a proposed example. It is unexecuted in this article. Rename symbols to match your module.

1. Isolate a throwaway branch

git switch -c char/config-merge
git status --short
Enter fullscreen mode Exit fullscreen mode

Stay on a branch. Keep main untouched. Do not mix formatters with characterization.

2. Record the messy entrypoint

Suppose settings.py exposes load_config(). It reads CONFIG_PATH. It also reads APP_* variables. It returns a plain dict.

It may call os.environ.setdefault. That mutation is part of the contract. Write it down before any extract.

# notes/messy_load_config.txt
entry: settings.load_config()
inputs: CONFIG_PATH, APP_DEBUG, APP_TIMEOUT, APP_FEATURE_X
output: dict
suspected mutation: os.environ.setdefault("APP_TIMEOUT", "30")
Enter fullscreen mode Exit fullscreen mode

3. Build a three-layer fixture

Create defaults in code. Write a temp file. Inject env vars in the test process only.

# tests/test_config_merge_characterization.py
from __future__ import annotations

import json
import os
from pathlib import Path

import pytest

SNAPSHOT = Path(__file__).with_name("config_merge.snapshot.json")

DEFAULTS = {
    "debug": False,
    "timeout": 30,
    "feature_x": False,
    "name": "app",
}


def _normalize(cfg: dict) -> dict:
    out = dict(cfg)
    out.pop("config_path", None)
    out.pop("loaded_at", None)
    return out
Enter fullscreen mode Exit fullscreen mode

Normalization drops unstable keys. Keep semantic keys only. That keeps the snapshot portable.

4. Drive the messy loader once

Patch env vars for the test process. Point CONFIG_PATH at a temp file. Call the real entrypoint.

def _write_file(tmp_path: Path) -> Path:
    path = tmp_path / "app.json"
    path.write_text(
        json.dumps({"timeout": 9, "name": "file-layer"}),
        encoding="utf-8",
    )
    return path


@pytest.fixture
def layered_env(tmp_path, monkeypatch):
    path = _write_file(tmp_path)
    monkeypatch.setenv("CONFIG_PATH", str(path))
    monkeypatch.setenv("APP_DEBUG", "true")
    monkeypatch.setenv("APP_FEATURE_X", "0")
    monkeypatch.delenv("APP_TIMEOUT", raising=False)
    return path
Enter fullscreen mode Exit fullscreen mode

APP_DEBUG=true should beat the default False. File timeout=9 should beat default 30. Missing APP_TIMEOUT should leave the file value. APP_FEATURE_X=0 should coerce to boolean False.

Those four outcomes define merge order. Guessing them is not a test.

5. Snapshot the merged dict

def test_load_config_merge_order(layered_env, monkeypatch):
    from settings import load_config

    before_env = dict(os.environ)
    cfg = _normalize(load_config())
    after_env = dict(os.environ)

    payload = {
        "config": cfg,
        "environ_mutated": before_env != after_env,
        "timeout_in_environ": os.environ.get("APP_TIMEOUT"),
    }

    if not SNAPSHOT.exists():
        SNAPSHOT.write_text(
            json.dumps(payload, sort_keys=True, indent=2) + "\n",
            encoding="utf-8",
        )
        pytest.fail("wrote snapshot; rerun to assert")

    expected = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
    assert payload == expected
Enter fullscreen mode Exit fullscreen mode

First run writes the file and fails. Second run asserts equality. That is the pin.

Commit the snapshot with the test. Review the JSON by hand. Reject surprise mutations of os.environ.

6. Prove a layer swap fails the pin

Add a second test that inverts precedence on purpose. Keep it skipped until you want a red demo.

@pytest.mark.skip(reason="local demo of a bad extract")
def test_file_must_not_beat_env_for_debug(monkeypatch, tmp_path):
    from settings import load_config

    path = tmp_path / "app.json"
    path.write_text(json.dumps({"debug": False}), encoding="utf-8")
    monkeypatch.setenv("CONFIG_PATH", str(path))
    monkeypatch.setenv("APP_DEBUG", "true")

    cfg = load_config()
    assert cfg["debug"] is True
Enter fullscreen mode Exit fullscreen mode

If an extract lets the file win, this test fails. The snapshot test should fail too. Two reds beat one story.

7. Smallest safe change: extract one coercer

Do not extract load_config() yet. Extract _coerce_bool() only.

# settings.py — proposed extract, verify against snapshot
_TRUTHY = {"1", "true", "yes", "on"}
_FALSY = {"0", "false", "no", "off", ""}


def _coerce_bool(value, default=False):
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    text = str(value).strip().lower()
    if text in _TRUTHY:
        return True
    if text in _FALSY:
        return False
    return default
Enter fullscreen mode Exit fullscreen mode

Call the new helper from the existing branches. Do not change key names. Do not add a class.

pytest tests/test_config_merge_characterization.py -q
Enter fullscreen mode Exit fullscreen mode

Green snapshot means coercion moved. Merge order did not. That is the smallest safe change.

If the snapshot flips, revert the extract. Restore the helper inline. Re-read the JSON diff.

Where a free model can enter

After the snapshot is green, a model can propose the coercer extract. It should not invent a Config class on the first pass.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can draft that single-helper patch against the pinned JSON. You still run pytest locally. You still reject any layer swap.

Paste the messy function, the snapshot, and the failing demo test. Ask for _coerce_bool only. Discard extra files the model adds.

Command checklist

  1. git switch -c char/config-merge
  2. Write tests/test_config_merge_characterization.py.
  3. Run pytest once to create the snapshot.
  4. Inspect JSON. Strip secrets and paths.
  5. Commit test plus snapshot together.
  6. Extract _coerce_bool only.
  7. Run pytest again. Revert on drift.

Keep the checklist linear. Do not parallelize extracts.

Limitations

This pin is not a schema. Unknown keys can still appear later. Add keys as new snapshot fields, one at a time.

monkeypatch does not cover subprocesses. Child processes read a different environ. Spawn tests need a separate harness.

JSON equality is brittle with float timeouts. Prefer ints for the first snapshot. Coerce floats in a later change.

Empty string versus missing key is a real fork. Snapshot both cases before you extract parsing.

Windows env names are case-insensitive. Linux names are not. Run the pin on the OS you ship.

Who should not use this

Skip this if you already have a typed settings library. Pydantic Settings and similar tools own merge order. Do not wrap them with a second snapshot.

Skip this if the loader fetches remote feature flags. Network state does not belong in a committed JSON file.

Skip this if the dict contains credentials. Characterization is not a secret store. Redact or replace those keys.

Skip this if you need a full rewrite this week. The method delays large extracts on purpose.

Closing rule

Merge order is a contract. Characterization writes that contract down. One coercer is the first legal edit.

Leave load_config() intact until the snapshot stays green. Then extract the next leaf, not the tree.

Top comments (0)