Override Pydantic Settings in Tests with Fixtures
Every configuration suite reaches the same wall: someone adds a required field, and forty tests that were never about that field start failing with a ValidationError. The tests were building the settings model themselves, each with its own slightly different set of values, so each has to be updated. A fixture factory — one valid baseline, overrides per test — makes that a one-line change instead, and along the way it fixes cache staleness and removes the temptation to mutate a shared object. This page builds that fixture, extending the configuration testing patterns.
Problem 1: every test constructing its own settings
# tests/test_billing.py — ANTI-PATTERN
def test_invoice_total():
settings = Settings(database_url="postgresql://u:p@db/app", secret_key="s")
... # repeated, differently, in forty files
Each of these is a partial copy of what a valid configuration looks like, and they drift: one file passes a redis_url, another does not, a third uses a different database name for no reason. When a new required field arrives, every one of them must be found and edited, and the edit is mechanical enough that people do it without thinking — which is how a test ends up asserting behaviour under a configuration that could never occur in production.
Centralising construction in a fixture removes the duplication and gives the suite a single answer to “what does a valid configuration look like?”. The baseline becomes the reference; tests express only their deviation from it, which is also what makes them readable — a test that overrides page_size=1 is visibly about pagination.
Problem 2: mutating a shared settings object
# ANTI-PATTERN
settings.debug = True # persists for anything holding this object
run_the_thing()
Mutation is attractive because it is one line, and it is wrong for two reasons. It persists — every other test, fixture and module-level reference sees the change until something sets it back, which nothing does. And it bypasses validation entirely: assigning directly can put the object into a state the model would have rejected, so the test exercises a configuration that could never exist in production.
Freezing the model with frozen=True turns the mutation into an immediate error, which is the right feedback. The replacement is model_copy(update={...}) for a derived object, or simply building a new instance from the baseline with the override applied. Both go through the same code path production uses, so what the test exercises is a configuration the application could actually be given.
Problem 3: a cached factory that outlives the change
# ANTI-PATTERN
monkeypatch.setenv("ENVIRONMENT", "production")
assert get_settings().environment == "production" # fails: cached from an earlier test
The lru_cache that makes get_settings() free in production makes it wrong in tests. The first call anywhere in the process — possibly during collection, when a module-level settings = get_settings() runs at import — populates the cache, and every later call returns that instance regardless of the environment.
Clearing the cache in the isolation fixture, both before and after each test, resolves it. Clearing on entry matters as much as on exit: an instance built during collection belongs to no test and will otherwise be handed to the first one that asks.
Secure implementation
# tests/conftest.py — the factory, the baseline, and the cache discipline
import pytest
from config import Settings, get_settings
BASELINE: dict[str, object] = {
# 1. a complete, valid configuration — the single reference for the whole suite
"environment": "test",
"database_url": "postgresql://u:p@db:5432/test",
"secret_key": "test-secret",
"redis_url": "redis://localhost:6379/0",
"page_size": 20,
}
@pytest.fixture(autouse=True)
def _clear_settings_cache():
get_settings.cache_clear() # 2. discard anything built during collection
yield
get_settings.cache_clear()
@pytest.fixture
def settings_factory():
# 3. init arguments outrank the environment, so no patching is needed
def _build(**overrides: object) -> Settings:
return Settings(**{**BASELINE, **overrides}, _env_file=None)
return _build
@pytest.fixture
def settings(settings_factory) -> Settings:
# 4. the common case: a valid configuration with nothing changed
return settings_factory()
# tests/test_settings.py
def test_pagination_uses_the_configured_size(settings_factory):
config = settings_factory(page_size=1) # 5. the test states only its deviation
assert paginate(range(10), config).pages == 10
@pytest.mark.parametrize("missing", ["database_url", "secret_key"])
def test_required_fields_are_required(missing):
values = {k: v for k, v in BASELINE.items() if k != missing}
with pytest.raises(ValidationError) as exc:
Settings(**values, _env_file=None)
assert exc.value.errors()[0]["loc"] == (missing,) # 6. assert the field, not the message
The dictionary-based baseline is what makes the parametrised requirement test possible, and that test is worth more than it looks. It proves that each field the application depends on genuinely fails when absent, which is the property the whole fail-fast strategy rests on. A field that quietly acquired a default during a refactor — someone adding = None to silence a local error — is caught here rather than in production, where it would present as a service running with an unset credential.
Passing _env_file=None inside the factory rather than at each call site is deliberate. It means no test can accidentally pick up a .env file from the repository root, regardless of what the developer has lying around, and it keeps the property in one place where it can be reviewed. The same applies to any secrets_dir: disable it in the factory and enable it explicitly in the handful of tests whose subject is file-based loading.
Sharing an expensive baseline safely
For most suites the factory is cheap enough to call per test. It stops being cheap when the model has a source that does real work — a secrets directory it reads, a remote provider it queries, a validator that compiles something expensive — and a suite with a thousand tests then pays that cost a thousand times for a value that never varies.
The safe optimisation is to build one instance at session scope and derive per-test variants from it with model_copy(update={...}), which produces a new validated object without re-running the sources. Because the model is frozen, the shared instance cannot be mutated by anything that receives it, so sharing is genuinely safe rather than safe-by-convention. What you must not do is share a mutable instance, which reintroduces cross-test coupling with extra steps.
There is a subtlety worth knowing: model_copy(update=...) does not re-validate the updated fields by default. That is exactly what you want for a fast derived object and exactly what you do not want when a test’s whole point is that a value is rejected. Keep rejection tests on full construction through the factory, and reserve model_copy for producing valid variants of an already-valid object. Mixing the two is how a suite ends up “proving” that an invalid value is accepted.
One habit makes the baseline age well: keep it minimal. Every optional field you add to it is a value the suite now depends on implicitly, and a default that changes will not be caught because the baseline overrides it. Include the required fields and the handful of optional ones that tests genuinely need to be deterministic — a page size, an environment name — and let everything else come from the model’s own defaults, so a change to one of those defaults shows up as a test failure rather than passing unnoticed.
Gotchas and version-specific behaviour
frozen=Trueinmodel_configmakes assignment raise, which is the feedback that stops mutation-based tests from being written at all.model_copy(update=...)skips validation for the updated fields; use full construction when validation is the subject of the test.- Initialisation arguments outrank environment variables in the default source order — that ordering is what the whole factory relies on.
lru_cacheon the factory must be cleared on both entry and exit, because an instance can be created at import during collection.- A baseline held as a dictionary supports the parametrised “is this field really required?” test; a baseline held as an instance does not.
- Fixtures that build application objects must depend on the settings fixture explicitly, or they will construct before the overrides are applied.
- Keep obviously-fake values in the baseline so secret scanners stay quiet and reviewers can see at a glance that nothing real is present.
Production parity checklist
- One baseline dictionary is the suite’s single definition of a valid configuration.
- A
settings_factoryfixture applies per-test overrides as keyword arguments. - The model is frozen, so no test can mutate a shared object.
- The settings cache is cleared before and after every test by an
autousefixture. - File sources are disabled inside the factory, not left to each call site.
- A parametrised test proves every required field fails when removed from the baseline.
Frequently asked questions
Why does my test still see the old settings after changing the environment?
The factory is cached. An lru_cache keeps the first instance for the life of the process, so a test that changes the environment gets the previously-built object until the cache is cleared. The instance is often created before any test runs at all — a module-level settings = get_settings() executes when pytest imports the module during collection — which is why clearing on fixture entry matters as much as clearing on exit. With the fixture factory in place this matters less anyway, because tests construct settings directly instead of going through the cached accessor.
Should I mutate a settings object in a test?
No — freeze the model and build a new one instead. A mutation persists for whatever else holds a reference to that object, and it bypasses validation, so a test can set a value the real application could never receive. frozen=True makes the attempt raise immediately, which converts a subtle cross-test bug into an obvious error at the point of the mistake. When a test needs a variant, the factory produces one in a single call, and the result went through the same validation the production path uses.
How do I stop every test breaking when I add a required field?
Build settings through a fixture factory with a complete valid baseline. Adding a field means updating the baseline once; tests that do not care about it never mention it. This is the main practical argument for the pattern — schema changes are frequent in an actively developed service, and the alternative is a mechanical edit across dozens of files each time, which is both tedious and an opportunity to introduce inconsistencies. The baseline also serves as documentation: it is the shortest complete answer to “what does this application need in order to run?”.
Do initialisation arguments beat environment variables?
Yes. In the default source order, values passed to the constructor take priority over environment variables, dotenv files and secret files, which is what makes per-test overrides a one-liner. The consequence worth internalising is that a test using the factory does not need monkeypatch at all — it is not manipulating ambient state, it is supplying a higher-priority source. Reserve environment patching for the tests whose actual subject is the environment, such as verifying that a particular variable name is read.
How do I test that a required field really is required?
Build the baseline as a dictionary, remove the key under test, and assert a ValidationError naming that field. Parametrising over the required fields covers the whole set in a few lines and keeps working as fields are added, provided you keep the parameter list beside the baseline. Assert on errors()[0]["loc"] rather than the message text, since locations are stable across library versions and messages are not — a point that matters most during exactly the upgrade where you would least like a wall of unrelated test failures.
Key takeaways
A fixture factory turns configuration into a first-class test input: one valid baseline that the suite agrees on, per-test overrides that say only what the test is about, and a frozen model so nothing can be quietly mutated. The payoff arrives the first time a required field is added and the change is one line in conftest.py rather than an edit across every test file that happened to build a model.
The supporting details are small but non-negotiable. Clear the cached factory on both sides of each test so no instance outlives its scope, disable file sources inside the factory rather than trusting each call site, and keep rejection tests on full construction so validation actually runs. With those in place the suite tests the schema you shipped rather than the machine it ran on — see the configuration testing topic for how this fits with environment isolation and mocked stores.