pytest monkeypatch for Environment Variables
A configuration test suite has one job that ordinary unit tests do not: it must control ambient state. Environment variables are process-global, so a test that sets one has changed the world for every test after it, and a test that reads one is reading whatever the previous test, the developer’s shell, or the CI runner’s defaults happened to leave behind. pytest’s monkeypatch fixture exists precisely for this, and using it consistently is the difference between a suite that means something and one that passes for reasons nobody can explain. This page covers the mechanics, extending the configuration testing patterns.
Problem 1: direct assignment that never gets reversed
# tests/test_config.py — ANTI-PATTERN
def test_production_mode():
os.environ["ENVIRONMENT"] = "production" # never removed
assert Settings().environment == "production"
The assertion passes and the variable stays set for the rest of the process. Every test that runs afterwards — in this file and every other file — now sees ENVIRONMENT=production, which is fine until one of them asserts a default, or constructs a model whose cross-field validator rejects a production configuration that lacks a real database URL.
The symptom is characteristic: the suite passes when run in its usual order and fails under pytest-randomly, under pytest -n auto, or when someone runs a single file. Worse, the failure surfaces in the victim test rather than the culprit, so the person debugging starts in the wrong place. monkeypatch.setenv records the previous value — including “was not set” — and restores it during teardown, which removes the possibility entirely rather than relying on discipline.
Problem 2: an isolation fixture that crashes on a clean machine
# conftest.py — ANTI-PATTERN
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
monkeypatch.delenv("DATABASE_URL") # KeyError where the variable was never set
delenv raises by default when the name is absent, so this fixture works on the developer’s machine — where a shell profile exports the variable — and fails on every CI runner, where it does not exist. Because the fixture is autouse, the failure hits every test in the suite at once, which tends to be diagnosed as “CI is broken” rather than as a bug in the fixture.
raising=False is the whole fix: the call becomes “ensure this is not set”, which is what an isolation fixture actually wants to express. The same flag exists on monkeypatch.delattr and delitem for the same reason, and reaching for it by default in cleanup code is a good habit.
Problem 3: reaching for monkeypatch in a wider-scoped fixture
# conftest.py — ANTI-PATTERN
@pytest.fixture(scope="session")
def api_env(monkeypatch): # ScopeMismatch: monkeypatch is function-scoped
monkeypatch.setenv("API_BASE", "https://test.invalid")
pytest refuses this outright with a ScopeMismatch error, and the refusal is correct — a function-scoped fixture cannot be safely injected into a session-scoped one, because its teardown would run long before the session fixture finished with it. The mistake is common when a suite wants one expensive setup shared across many tests.
The supported route is to instantiate the class yourself and manage its lifetime. pytest.MonkeyPatch.context() gives a context manager, or you can construct one and call undo() in the fixture’s teardown. Either way you have opted into a wider scope deliberately, which is the point: environment changes that outlive a single test are a real decision and should look like one in the code.
Secure implementation
# tests/conftest.py — isolation, a setter, and a correctly-scoped wide patch
import pytest
from config import get_settings
APP_VARS = ("DATABASE_URL", "SECRET_KEY", "REDIS_URL", "ENVIRONMENT", "DEBUG", "API_TOKEN")
@pytest.fixture(autouse=True)
def isolate_env(monkeypatch):
# 1. raising=False so a clean machine is not an error
for name in APP_VARS:
monkeypatch.delenv(name, raising=False)
# 2. discard any settings instance built during collection
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def env(monkeypatch):
# 3. a tiny helper so each test states its own inputs on one line
def _set(**values: str) -> None:
for name, value in values.items():
monkeypatch.setenv(name.upper(), str(value))
return _set
@pytest.fixture(scope="session")
def session_env():
# 4. a wider scope requires an explicit instance and an explicit undo
mp = pytest.MonkeyPatch()
mp.setenv("TZ", "UTC") # process-wide, genuinely shared
yield mp
mp.undo()
# tests/test_settings.py
def test_defaults_apply_when_nothing_is_set(env):
# 5. the autouse fixture guarantees an empty slate, so this asserts the default
env(DATABASE_URL="postgresql://u:p@db/app", SECRET_KEY="s")
assert Settings(_env_file=None).environment == "local"
def test_environment_is_read_from_the_process(env):
env(DATABASE_URL="postgresql://u:p@db/app", SECRET_KEY="s", ENVIRONMENT="staging")
assert Settings(_env_file=None).environment == "staging"
The APP_VARS tuple is deliberately explicit rather than a wildcard clear of os.environ. Wiping the whole environment breaks things tests legitimately depend on — PATH, HOME, TMPDIR, the variables a database client or a TLS library reads — and produces failures that look nothing like a configuration problem. Listing your application’s own variables keeps the blast radius exactly where it belongs, and the list doubles as documentation of what the application reads.
Note the str(value) coercion in the setter. os.environ accepts only strings, and passing an integer raises a TypeError from deep inside the standard library that reads like a bug in pytest. Coercing at the helper means a test can write env(PORT=8000) naturally, which matters because tests that are awkward to write get written less carefully.
When monkeypatch is not the right tool
Environment variables are not the only ambient input a settings model reads, and monkeypatch only covers some of them. A .env file on disk is found relative to the working directory, so no amount of environment patching removes it — pass _env_file=None at construction, or point the model at a path under tmp_path. A secrets_dir behaves the same way. And a remote secret source needs a substituted client rather than a patched variable, because the value never passes through the environment at all.
There is also a case where patching the environment is technically possible and still the wrong choice: overriding a single field for one test. Settings(environment="staging", **valid_baseline) says what it means in one line, and initialisation arguments outrank environment variables in the default source order, so it works without touching the process at all. Reserve environment patching for tests whose subject is the environment — that a variable is read, that its name is what you think, that precedence works — and use direct construction everywhere else.
Finally, monkeypatch.chdir deserves a mention alongside setenv, because working directory is the other piece of ambient state a settings model is sensitive to. A test that verifies .env discovery has to control the directory, and doing it with monkeypatch.chdir(tmp_path) gets the same automatic restoration that setenv gives, where a bare os.chdir leaves the whole process pointing somewhere else for the rest of the run.
One further boundary is worth naming because it catches people converting a legacy suite. Fixtures unwind in reverse order of setup, so a fixture that constructs an object before the environment is patched will hold values from the unpatched state, no matter how correct the patching itself is. The usual shape of the bug is a session-scoped client or application fixture that reads configuration at construction, combined with a function-scoped environment patch that runs afterwards. Order the dependencies explicitly — have the object-building fixture request the environment fixture — and the sequencing becomes something pytest enforces rather than something you hope holds.
Gotchas and version-specific behaviour
monkeypatchis function-scoped; usepytest.MonkeyPatch()with an explicitundo()for anything wider.delenv(..., raising=False)is required for isolation fixtures; the default raises on absent names.setenvaccepts strings only — coerce in a helper so tests can pass integers naturally.- Changes are visible to subprocesses, since they inherit the patched
os.environ. monkeypatch.setenv(..., prepend=os.pathsep)prepends to an existing value, which is useful forPATH-shaped variables and wrong for everything else.- Patching the environment does not affect an already-constructed settings object; clear any cache before rebuilding.
- Under
pytest-xdist, each worker is a separate process with its own environment, so patches never cross workers — but a shared file on disk still can.
Production parity checklist
- One
autousefixture clears the application’s own variables and the settings cache, listed explicitly. - Every test that needs a variable sets it through
monkeypatch, never through direct assignment. - Tests that care about a single field construct the model with keyword arguments instead of patching.
- File-based sources are disabled with
_env_file=Noneor redirected totmp_path. - The suite passes under
pytest -p randomlyandpytest -n auto, which is the real proof of isolation. - CI runs the suite in a container with an empty environment so nothing can be inherited unnoticed.
Frequently asked questions
Why not just assign to os.environ in a test?
Because nothing reverses it. The value survives into every later test in the process, so the suite passes in file order and fails under randomisation or parallel execution, with the failure appearing in a test that did nothing wrong. That indirection is what makes the bug expensive: the person investigating starts from the failing test, which is innocent, and only finds the culprit by bisecting the file order. monkeypatch.setenv records the prior state — including the absence of a variable — and restores it in teardown, so the class of bug simply cannot occur no matter how careless an individual test is.
Can I use monkeypatch in a session-scoped fixture?
Not the built-in one — monkeypatch is function-scoped, so pytest refuses to inject it into a wider fixture with a ScopeMismatch error. Build a broader-scoped pytest.MonkeyPatch instance explicitly and call undo() in the teardown, or use pytest.MonkeyPatch.context() as a context manager. The friction is intentional: an environment change that outlives a single test affects every test that follows it, which is a decision worth writing out rather than acquiring by accident through a fixture’s scope.
How do I remove a variable that might not be set?
monkeypatch.delenv("NAME", raising=False). Without raising=False the call fails on any machine where the variable was never set, which makes an autouse isolation fixture unusable — it works for whoever wrote it and breaks every CI runner. The same flag is available on delattr and delitem, and the general rule holds for all three: cleanup code should express “ensure this is absent”, not “assert this was present and now remove it”.
Should the isolation fixture clear every variable or a named list?
A named list, every time. Clearing the whole environment removes PATH, HOME, TMPDIR and whatever a database driver, TLS library or coverage tool depends on, and the resulting failures look nothing like configuration problems — a test that cannot find a binary, an SSL handshake that stops working, a temporary file that lands in the wrong place. Listing your application’s own variables keeps the effect precisely scoped, and it has a useful side effect: the list is a maintained inventory of what the application reads, which is exactly the thing a new contributor asks for and nothing else in the codebase records.
Does monkeypatch affect subprocesses?
Yes. It changes os.environ in the current process, and subprocesses inherit that environment, so a test that spawns a process sees the patched values. That is usually exactly what you want — it lets you test a CLI entry point or a worker process under a controlled configuration — but it is occasionally a surprise when a test shells out to a tool that reads one of the variables you cleared. If a subprocess needs a different environment from its parent, pass an explicit env argument rather than patching around it.
Key takeaways
monkeypatch turns environment manipulation from something a test must remember to undo into something that is undone automatically, and that single property is what makes a configuration suite trustworthy under randomised and parallel execution. The three details that matter in practice are raising=False for deletions, an explicit MonkeyPatch instance when you genuinely need a wider scope, and an isolation fixture that lists your application’s variables rather than wiping the environment wholesale.
It is also worth knowing where the tool stops. Files on disk, secrets directories and remote stores are ambient inputs that no environment patch reaches, and a single-field override is cleaner as a construction argument than as a patched variable. Match the tool to the input and each test ends up stating exactly what it depends on — which is the property that makes a failure mean something. The broader picture, including cache clearing and what is worth asserting, is on the configuration testing topic page.