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.
- Precedence for every key under test.
- Coercion for bools, ints, and empty strings.
- Whether
os.environmutates 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
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")
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
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
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
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
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
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
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
git switch -c char/config-merge- Write
tests/test_config_merge_characterization.py. - Run pytest once to create the snapshot.
- Inspect JSON. Strip secrets and paths.
- Commit test plus snapshot together.
- Extract
_coerce_boolonly. - 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)